diff --git a/lib/python3.12/site-packages/httpx-0.28.1.dist-info/INSTALLER b/lib/python3.12/site-packages/httpx-0.28.1.dist-info/INSTALLER new file mode 100644 index 0000000000000000000000000000000000000000..a1b589e38a32041e49332e5e81c2d363dc418d68 --- /dev/null +++ b/lib/python3.12/site-packages/httpx-0.28.1.dist-info/INSTALLER @@ -0,0 +1 @@ +pip diff --git a/lib/python3.12/site-packages/httpx-0.28.1.dist-info/METADATA b/lib/python3.12/site-packages/httpx-0.28.1.dist-info/METADATA new file mode 100644 index 0000000000000000000000000000000000000000..b0d2b196385e98259971519793447c1fd7a9a643 --- /dev/null +++ b/lib/python3.12/site-packages/httpx-0.28.1.dist-info/METADATA @@ -0,0 +1,203 @@ +Metadata-Version: 2.3 +Name: httpx +Version: 0.28.1 +Summary: The next generation HTTP client. +Project-URL: Changelog, https://github.com/encode/httpx/blob/master/CHANGELOG.md +Project-URL: Documentation, https://www.python-httpx.org +Project-URL: Homepage, https://github.com/encode/httpx +Project-URL: Source, https://github.com/encode/httpx +Author-email: Tom Christie +License: BSD-3-Clause +Classifier: Development Status :: 4 - Beta +Classifier: Environment :: Web Environment +Classifier: Framework :: AsyncIO +Classifier: Framework :: Trio +Classifier: Intended Audience :: Developers +Classifier: License :: OSI Approved :: BSD License +Classifier: Operating System :: OS Independent +Classifier: Programming Language :: Python :: 3 +Classifier: Programming Language :: Python :: 3 :: Only +Classifier: Programming Language :: Python :: 3.8 +Classifier: Programming Language :: Python :: 3.9 +Classifier: Programming Language :: Python :: 3.10 +Classifier: Programming Language :: Python :: 3.11 +Classifier: Programming Language :: Python :: 3.12 +Classifier: Topic :: Internet :: WWW/HTTP +Requires-Python: >=3.8 +Requires-Dist: anyio +Requires-Dist: certifi +Requires-Dist: httpcore==1.* +Requires-Dist: idna +Provides-Extra: brotli +Requires-Dist: brotli; (platform_python_implementation == 'CPython') and extra == 'brotli' +Requires-Dist: brotlicffi; (platform_python_implementation != 'CPython') and extra == 'brotli' +Provides-Extra: cli +Requires-Dist: click==8.*; extra == 'cli' +Requires-Dist: pygments==2.*; extra == 'cli' +Requires-Dist: rich<14,>=10; extra == 'cli' +Provides-Extra: http2 +Requires-Dist: h2<5,>=3; extra == 'http2' +Provides-Extra: socks +Requires-Dist: socksio==1.*; extra == 'socks' +Provides-Extra: zstd +Requires-Dist: zstandard>=0.18.0; extra == 'zstd' +Description-Content-Type: text/markdown + +

+ HTTPX +

+ +

HTTPX - A next-generation HTTP client for Python.

+ +

+ + Test Suite + + + Package version + +

+ +HTTPX is a fully featured HTTP client library for Python 3. It includes **an integrated command line client**, has support for both **HTTP/1.1 and HTTP/2**, and provides both **sync and async APIs**. + +--- + +Install HTTPX using pip: + +```shell +$ pip install httpx +``` + +Now, let's get started: + +```pycon +>>> import httpx +>>> r = httpx.get('https://www.example.org/') +>>> r + +>>> r.status_code +200 +>>> r.headers['content-type'] +'text/html; charset=UTF-8' +>>> r.text +'\n\n\nExample Domain...' +``` + +Or, using the command-line client. + +```shell +$ pip install 'httpx[cli]' # The command line client is an optional dependency. +``` + +Which now allows us to use HTTPX directly from the command-line... + +

+ httpx --help +

+ +Sending a request... + +

+ httpx http://httpbin.org/json +

+ +## Features + +HTTPX builds on the well-established usability of `requests`, and gives you: + +* A broadly [requests-compatible API](https://www.python-httpx.org/compatibility/). +* An integrated command-line client. +* HTTP/1.1 [and HTTP/2 support](https://www.python-httpx.org/http2/). +* Standard synchronous interface, but with [async support if you need it](https://www.python-httpx.org/async/). +* Ability to make requests directly to [WSGI applications](https://www.python-httpx.org/advanced/transports/#wsgi-transport) or [ASGI applications](https://www.python-httpx.org/advanced/transports/#asgi-transport). +* Strict timeouts everywhere. +* Fully type annotated. +* 100% test coverage. + +Plus all the standard features of `requests`... + +* International Domains and URLs +* Keep-Alive & Connection Pooling +* Sessions with Cookie Persistence +* Browser-style SSL Verification +* Basic/Digest Authentication +* Elegant Key/Value Cookies +* Automatic Decompression +* Automatic Content Decoding +* Unicode Response Bodies +* Multipart File Uploads +* HTTP(S) Proxy Support +* Connection Timeouts +* Streaming Downloads +* .netrc Support +* Chunked Requests + +## Installation + +Install with pip: + +```shell +$ pip install httpx +``` + +Or, to include the optional HTTP/2 support, use: + +```shell +$ pip install httpx[http2] +``` + +HTTPX requires Python 3.8+. + +## Documentation + +Project documentation is available at [https://www.python-httpx.org/](https://www.python-httpx.org/). + +For a run-through of all the basics, head over to the [QuickStart](https://www.python-httpx.org/quickstart/). + +For more advanced topics, see the [Advanced Usage](https://www.python-httpx.org/advanced/) section, the [async support](https://www.python-httpx.org/async/) section, or the [HTTP/2](https://www.python-httpx.org/http2/) section. + +The [Developer Interface](https://www.python-httpx.org/api/) provides a comprehensive API reference. + +To find out about tools that integrate with HTTPX, see [Third Party Packages](https://www.python-httpx.org/third_party_packages/). + +## Contribute + +If you want to contribute with HTTPX check out the [Contributing Guide](https://www.python-httpx.org/contributing/) to learn how to start. + +## Dependencies + +The HTTPX project relies on these excellent libraries: + +* `httpcore` - The underlying transport implementation for `httpx`. + * `h11` - HTTP/1.1 support. +* `certifi` - SSL certificates. +* `idna` - Internationalized domain name support. +* `sniffio` - Async library autodetection. + +As well as these optional installs: + +* `h2` - HTTP/2 support. *(Optional, with `httpx[http2]`)* +* `socksio` - SOCKS proxy support. *(Optional, with `httpx[socks]`)* +* `rich` - Rich terminal support. *(Optional, with `httpx[cli]`)* +* `click` - Command line client support. *(Optional, with `httpx[cli]`)* +* `brotli` or `brotlicffi` - Decoding for "brotli" compressed responses. *(Optional, with `httpx[brotli]`)* +* `zstandard` - Decoding for "zstd" compressed responses. *(Optional, with `httpx[zstd]`)* + +A huge amount of credit is due to `requests` for the API layout that +much of this work follows, as well as to `urllib3` for plenty of design +inspiration around the lower-level networking details. + +--- + +

HTTPX is BSD licensed code.
Designed & crafted with care.

— 🦋 —

+ +## Release Information + +### Fixed + +* Reintroduced supposedly-private `URLTypes` shortcut. (#2673) + + +--- + +[Full changelog](https://github.com/encode/httpx/blob/master/CHANGELOG.md) diff --git a/lib/python3.12/site-packages/httpx-0.28.1.dist-info/RECORD b/lib/python3.12/site-packages/httpx-0.28.1.dist-info/RECORD new file mode 100644 index 0000000000000000000000000000000000000000..799a57227ac91108da61ab68b7ab484dc4a4aeca --- /dev/null +++ b/lib/python3.12/site-packages/httpx-0.28.1.dist-info/RECORD @@ -0,0 +1,54 @@ +../../../bin/httpx,sha256=__uvRZOfKZGPYqahkhnsmchtxRnGtxgzZUmItZywotE,240 +httpx-0.28.1.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4 +httpx-0.28.1.dist-info/METADATA,sha256=_rubD48-gNV8gZnDBPNcQzboWB0dGNeYPJJ2a4J5OyU,7052 +httpx-0.28.1.dist-info/RECORD,, +httpx-0.28.1.dist-info/WHEEL,sha256=C2FUgwZgiLbznR-k0b_5k3Ai_1aASOXDss3lzCUsUug,87 +httpx-0.28.1.dist-info/entry_points.txt,sha256=2lVkdQmxLA1pNMgSN2eV89o90HCZezhmNwsy6ryKDSA,37 +httpx-0.28.1.dist-info/licenses/LICENSE.md,sha256=TsWdVE8StfU5o6cW_TIaxYzNgDC0ZSIfLIgCAM3yjY0,1508 +httpx/__init__.py,sha256=CsaZe6yZj0rHg6322AWKWHGTMVr9txgEfD5P3_Rrz60,2171 +httpx/__pycache__/__init__.cpython-312.pyc,, +httpx/__pycache__/__version__.cpython-312.pyc,, +httpx/__pycache__/_api.cpython-312.pyc,, +httpx/__pycache__/_auth.cpython-312.pyc,, +httpx/__pycache__/_client.cpython-312.pyc,, +httpx/__pycache__/_config.cpython-312.pyc,, +httpx/__pycache__/_content.cpython-312.pyc,, +httpx/__pycache__/_decoders.cpython-312.pyc,, +httpx/__pycache__/_exceptions.cpython-312.pyc,, +httpx/__pycache__/_main.cpython-312.pyc,, +httpx/__pycache__/_models.cpython-312.pyc,, +httpx/__pycache__/_multipart.cpython-312.pyc,, +httpx/__pycache__/_status_codes.cpython-312.pyc,, +httpx/__pycache__/_types.cpython-312.pyc,, +httpx/__pycache__/_urlparse.cpython-312.pyc,, +httpx/__pycache__/_urls.cpython-312.pyc,, +httpx/__pycache__/_utils.cpython-312.pyc,, +httpx/__version__.py,sha256=LoUyYeOXTieGzuP_64UL0wxdtxjuu_QbOvE7NOg-IqU,108 +httpx/_api.py,sha256=r_Zgs4jIpcPJLqK5dbbSayqo_iVMKFaxZCd-oOHxLEs,11743 +httpx/_auth.py,sha256=Yr3QwaUSK17rGYx-7j-FdicFIzz4Y9FFV-1F4-7RXX4,11891 +httpx/_client.py,sha256=xD-UG67-WMkeltAAOeGGj-cZ2RRTAm19sWRxlFY7_40,65714 +httpx/_config.py,sha256=pPp2U-wicfcKsF-KYRE1LYdt3e6ERGeIoXZ8Gjo3LWc,8547 +httpx/_content.py,sha256=LGGzrJTR3OvN4Mb1GVVNLXkXJH-6oKlwAttO9p5w_yg,8161 +httpx/_decoders.py,sha256=p0dX8I0NEHexs3UGp4SsZutiMhsXrrWl6-GnqVb0iKM,12041 +httpx/_exceptions.py,sha256=bxW7fxzgVMAdNTbwT0Vnq04gJDW1_gI_GFiQPuMyjL0,8527 +httpx/_main.py,sha256=Cg9GMabiTT_swaDfUgIRitSwxLRMSwUDOm7LdSGqlA4,15626 +httpx/_models.py,sha256=4__Guyv1gLxuZChwim8kfQNiIOcJ9acreFOSurvZfms,44700 +httpx/_multipart.py,sha256=KOHEZZl6oohg9mPaKyyu345qq1rJLg35TUG3YAzXB3Y,9843 +httpx/_status_codes.py,sha256=DYn-2ufBgMeXy5s8x3_TB7wjAuAAMewTakPrm5rXEsc,5639 +httpx/_transports/__init__.py,sha256=GbUoBSAOp7z-l-9j5YhMhR3DMIcn6FVLhj072O3Nnno,275 +httpx/_transports/__pycache__/__init__.cpython-312.pyc,, +httpx/_transports/__pycache__/asgi.cpython-312.pyc,, +httpx/_transports/__pycache__/base.cpython-312.pyc,, +httpx/_transports/__pycache__/default.cpython-312.pyc,, +httpx/_transports/__pycache__/mock.cpython-312.pyc,, +httpx/_transports/__pycache__/wsgi.cpython-312.pyc,, +httpx/_transports/asgi.py,sha256=HRfiDYMPt4wQH2gFgHZg4c-i3sblo6bL5GTqcET-xz8,5501 +httpx/_transports/base.py,sha256=kZS_VMbViYfF570pogUCJ1bulz-ybfL51Pqs9yktebU,2523 +httpx/_transports/default.py,sha256=AzeaRUyVwCccTyyNJexDf0n1dFfzzydpdIQgvw7PLnk,13983 +httpx/_transports/mock.py,sha256=PTo0d567RITXxGrki6kN7_67wwAxfwiMDcuXJiZCjEo,1232 +httpx/_transports/wsgi.py,sha256=NcPX3Xap_EwCFZWO_OaSyQNuInCYx1QMNbO8GAei6jY,4825 +httpx/_types.py,sha256=Jyh41GQq7AOev8IOWKDAg7zCbvHAfufmW5g_PiTtErY,2965 +httpx/_urlparse.py,sha256=ZAmH47ONfkxrrj-PPYhGeiHjb6AjKCS-ANWIN4OL_KY,18546 +httpx/_urls.py,sha256=dX99VR1DSOHpgo9Aq7PzYO4FKdxqKjwyNp8grf8dHN0,21550 +httpx/_utils.py,sha256=_TVeqAKvxJkKHdz7dFeb4s0LZqQXgeFkXSgfiHBK_1o,8285 +httpx/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 diff --git a/lib/python3.12/site-packages/httpx-0.28.1.dist-info/WHEEL b/lib/python3.12/site-packages/httpx-0.28.1.dist-info/WHEEL new file mode 100644 index 0000000000000000000000000000000000000000..21aaa72961a8af71c17d2cb3b76d5f7f567100e4 --- /dev/null +++ b/lib/python3.12/site-packages/httpx-0.28.1.dist-info/WHEEL @@ -0,0 +1,4 @@ +Wheel-Version: 1.0 +Generator: hatchling 1.26.3 +Root-Is-Purelib: true +Tag: py3-none-any diff --git a/lib/python3.12/site-packages/httpx-0.28.1.dist-info/entry_points.txt b/lib/python3.12/site-packages/httpx-0.28.1.dist-info/entry_points.txt new file mode 100644 index 0000000000000000000000000000000000000000..8ae96007f7d725813fd02dc1d06d3834ee1939e4 --- /dev/null +++ b/lib/python3.12/site-packages/httpx-0.28.1.dist-info/entry_points.txt @@ -0,0 +1,2 @@ +[console_scripts] +httpx = httpx:main diff --git a/lib/python3.12/site-packages/httpx-0.28.1.dist-info/licenses/LICENSE.md b/lib/python3.12/site-packages/httpx-0.28.1.dist-info/licenses/LICENSE.md new file mode 100644 index 0000000000000000000000000000000000000000..ab79d16a3f4c6c894c028d1f7431811e8711b42b --- /dev/null +++ b/lib/python3.12/site-packages/httpx-0.28.1.dist-info/licenses/LICENSE.md @@ -0,0 +1,12 @@ +Copyright © 2019, [Encode OSS Ltd](https://www.encode.io/). +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +* Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/lib/python3.12/site-packages/markdown/__init__.py b/lib/python3.12/site-packages/markdown/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..9674d6e060fd0633f920699f030ef447b025d58e --- /dev/null +++ b/lib/python3.12/site-packages/markdown/__init__.py @@ -0,0 +1,48 @@ +# Python Markdown + +# A Python implementation of John Gruber's Markdown. + +# - Documentation: https://python-markdown.github.io/ +# - GitHub: https://github.com/Python-Markdown/markdown/ +# - PyPI: https://pypi.org/project/Markdown/ + +# Started by Manfred Stienstra (http://www.dwerg.net/). +# Maintained for a few years by Yuri Takhteyev (http://www.freewisdom.org). +# Currently maintained by Waylan Limberg (https://github.com/waylan), +# Dmitry Shachnev (https://github.com/mitya57) and Isaac Muse (https://github.com/facelessuser). + +# - Copyright 2007-2023 The Python Markdown Project (v. 1.7 and later) +# - Copyright 2004, 2005, 2006 Yuri Takhteyev (v. 0.2-1.6b) +# - Copyright 2004 Manfred Stienstra (the original version) + +# License: BSD (see LICENSE.md for details). + +""" +Python-Markdown provides two public functions ([`markdown.markdown`][] and [`markdown.markdownFromFile`][]) +both of which wrap the public class [`markdown.Markdown`][]. All submodules support these public functions +and class and/or provide extension support. + +Modules: + core: Core functionality. + preprocessors: Pre-processors. + blockparser: Core Markdown block parser. + blockprocessors: Block processors. + treeprocessors: Tree processors. + inlinepatterns: Inline patterns. + postprocessors: Post-processors. + serializers: Serializers. + util: Utility functions. + htmlparser: HTML parser. + test_tools: Testing utilities. + extensions: Markdown extensions. +""" + +from __future__ import annotations + +from .core import Markdown, markdown, markdownFromFile +from .__meta__ import __version__, __version_info__ # noqa + +# For backward compatibility as some extensions expect it... +from .extensions import Extension # noqa + +__all__ = ['Markdown', 'markdown', 'markdownFromFile'] diff --git a/lib/python3.12/site-packages/markdown/__main__.py b/lib/python3.12/site-packages/markdown/__main__.py new file mode 100644 index 0000000000000000000000000000000000000000..259df6336f8829b27649a6f19058646d662a9213 --- /dev/null +++ b/lib/python3.12/site-packages/markdown/__main__.py @@ -0,0 +1,150 @@ +# Python Markdown + +# A Python implementation of John Gruber's Markdown. + +# Documentation: https://python-markdown.github.io/ +# GitHub: https://github.com/Python-Markdown/markdown/ +# PyPI: https://pypi.org/project/Markdown/ + +# Started by Manfred Stienstra (http://www.dwerg.net/). +# Maintained for a few years by Yuri Takhteyev (http://www.freewisdom.org). +# Currently maintained by Waylan Limberg (https://github.com/waylan), +# Dmitry Shachnev (https://github.com/mitya57) and Isaac Muse (https://github.com/facelessuser). + +# Copyright 2007-2023 The Python Markdown Project (v. 1.7 and later) +# Copyright 2004, 2005, 2006 Yuri Takhteyev (v. 0.2-1.6b) +# Copyright 2004 Manfred Stienstra (the original version) + +# License: BSD (see LICENSE.md for details). + +from __future__ import annotations + +import sys +import optparse +import warnings +import markdown +try: + # We use `unsafe_load` because users may need to pass in actual Python + # objects. As this is only available from the CLI, the user has much + # worse problems if an attacker can use this as an attach vector. + from yaml import unsafe_load as yaml_load +except ImportError: # pragma: no cover + try: + # Fall back to PyYAML <5.1 + from yaml import load as yaml_load + except ImportError: + # Fall back to JSON + from json import load as yaml_load + +import logging +from logging import DEBUG, WARNING, CRITICAL + +logger = logging.getLogger('MARKDOWN') + + +def parse_options(args=None, values=None): + """ + Define and parse `optparse` options for command-line usage. + """ + usage = """%prog [options] [INPUTFILE] + (STDIN is assumed if no INPUTFILE is given)""" + desc = "A Python implementation of John Gruber's Markdown. " \ + "https://Python-Markdown.github.io/" + ver = "%%prog %s" % markdown.__version__ + + parser = optparse.OptionParser(usage=usage, description=desc, version=ver) + parser.add_option("-f", "--file", dest="filename", default=None, + help="Write output to OUTPUT_FILE. Defaults to STDOUT.", + metavar="OUTPUT_FILE") + parser.add_option("-e", "--encoding", dest="encoding", + help="Encoding for input and output files.",) + parser.add_option("-o", "--output_format", dest="output_format", + default='xhtml', metavar="OUTPUT_FORMAT", + help="Use output format 'xhtml' (default) or 'html'.") + parser.add_option("-n", "--no_lazy_ol", dest="lazy_ol", + action='store_false', default=True, + help="Observe number of first item of ordered lists.") + parser.add_option("-x", "--extension", action="append", dest="extensions", + help="Load extension EXTENSION.", metavar="EXTENSION") + parser.add_option("-c", "--extension_configs", + dest="configfile", default=None, + help="Read extension configurations from CONFIG_FILE. " + "CONFIG_FILE must be of JSON or YAML format. YAML " + "format requires that a python YAML library be " + "installed. The parsed JSON or YAML must result in a " + "python dictionary which would be accepted by the " + "'extension_configs' keyword on the markdown.Markdown " + "class. The extensions must also be loaded with the " + "`--extension` option.", + metavar="CONFIG_FILE") + parser.add_option("-q", "--quiet", default=CRITICAL, + action="store_const", const=CRITICAL+10, dest="verbose", + help="Suppress all warnings.") + parser.add_option("-v", "--verbose", + action="store_const", const=WARNING, dest="verbose", + help="Print all warnings.") + parser.add_option("--noisy", + action="store_const", const=DEBUG, dest="verbose", + help="Print debug messages.") + + (options, args) = parser.parse_args(args, values) + + if len(args) == 0: + input_file = None + else: + input_file = args[0] + + if not options.extensions: + options.extensions = [] + + extension_configs = {} + if options.configfile: + with open( + options.configfile, mode="r", encoding=options.encoding + ) as fp: + try: + extension_configs = yaml_load(fp) + except Exception as e: + message = "Failed parsing extension config file: %s" % \ + options.configfile + e.args = (message,) + e.args[1:] + raise + + opts = { + 'input': input_file, + 'output': options.filename, + 'extensions': options.extensions, + 'extension_configs': extension_configs, + 'encoding': options.encoding, + 'output_format': options.output_format, + 'lazy_ol': options.lazy_ol + } + + return opts, options.verbose + + +def run(): # pragma: no cover + """Run Markdown from the command line.""" + + # Parse options and adjust logging level if necessary + options, logging_level = parse_options() + if not options: + sys.exit(2) + logger.setLevel(logging_level) + console_handler = logging.StreamHandler() + logger.addHandler(console_handler) + if logging_level <= WARNING: + # Ensure deprecation warnings get displayed + warnings.filterwarnings('default') + logging.captureWarnings(True) + warn_logger = logging.getLogger('py.warnings') + warn_logger.addHandler(console_handler) + + # Run + markdown.markdownFromFile(**options) + + +if __name__ == '__main__': # pragma: no cover + # Support running module as a command line command. + # python -m markdown [options] [args] + run() diff --git a/lib/python3.12/site-packages/markdown/__meta__.py b/lib/python3.12/site-packages/markdown/__meta__.py new file mode 100644 index 0000000000000000000000000000000000000000..5e1ae18d80056b19c1c377d3b1135cad9f91711d --- /dev/null +++ b/lib/python3.12/site-packages/markdown/__meta__.py @@ -0,0 +1,51 @@ +# Python Markdown + +# A Python implementation of John Gruber's Markdown. + +# Documentation: https://python-markdown.github.io/ +# GitHub: https://github.com/Python-Markdown/markdown/ +# PyPI: https://pypi.org/project/Markdown/ + +# Started by Manfred Stienstra (http://www.dwerg.net/). +# Maintained for a few years by Yuri Takhteyev (http://www.freewisdom.org). +# Currently maintained by Waylan Limberg (https://github.com/waylan), +# Dmitry Shachnev (https://github.com/mitya57) and Isaac Muse (https://github.com/facelessuser). + +# Copyright 2007-2023 The Python Markdown Project (v. 1.7 and later) +# Copyright 2004, 2005, 2006 Yuri Takhteyev (v. 0.2-1.6b) +# Copyright 2004 Manfred Stienstra (the original version) + +# License: BSD (see LICENSE.md for details). + +# __version_info__ format: +# (major, minor, patch, dev/alpha/beta/rc/final, #) +# (1, 1, 2, 'dev', 0) => "1.1.2.dev0" +# (1, 1, 2, 'alpha', 1) => "1.1.2a1" +# (1, 2, 0, 'beta', 2) => "1.2b2" +# (1, 2, 0, 'rc', 4) => "1.2rc4" +# (1, 2, 0, 'final', 0) => "1.2" + +from __future__ import annotations + + +__version_info__ = (3, 10, 1, 'final', 0) + + +def _get_version(version_info): + " Returns a PEP 440-compliant version number from `version_info`. " + assert len(version_info) == 5 + assert version_info[3] in ('dev', 'alpha', 'beta', 'rc', 'final') + + parts = 2 if version_info[2] == 0 else 3 + v = '.'.join(map(str, version_info[:parts])) + + if version_info[3] == 'dev': + v += '.dev' + str(version_info[4]) + elif version_info[3] != 'final': + mapping = {'alpha': 'a', 'beta': 'b', 'rc': 'rc'} + v += mapping[version_info[3]] + str(version_info[4]) + + return v + + +__version__ = _get_version(__version_info__) diff --git a/lib/python3.12/site-packages/markdown/__pycache__/__init__.cpython-312.pyc b/lib/python3.12/site-packages/markdown/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..95e24bdf0ff2fc85226d8cf9dac2255f4fd3920b Binary files /dev/null and b/lib/python3.12/site-packages/markdown/__pycache__/__init__.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/markdown/__pycache__/__main__.cpython-312.pyc b/lib/python3.12/site-packages/markdown/__pycache__/__main__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3ce1104ba7786092599918ff1753ce0986aeee0e Binary files /dev/null and b/lib/python3.12/site-packages/markdown/__pycache__/__main__.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/markdown/__pycache__/__meta__.cpython-312.pyc b/lib/python3.12/site-packages/markdown/__pycache__/__meta__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ee458e6f7a64c752a318bc3c9695302570b106b2 Binary files /dev/null and b/lib/python3.12/site-packages/markdown/__pycache__/__meta__.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/markdown/__pycache__/blockparser.cpython-312.pyc b/lib/python3.12/site-packages/markdown/__pycache__/blockparser.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..388555438a520fc007897ec5eaaa4a9745234754 Binary files /dev/null and b/lib/python3.12/site-packages/markdown/__pycache__/blockparser.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/markdown/__pycache__/blockprocessors.cpython-312.pyc b/lib/python3.12/site-packages/markdown/__pycache__/blockprocessors.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6573a72a3a1fb4955460625d4da6d0c06c1cfb31 Binary files /dev/null and b/lib/python3.12/site-packages/markdown/__pycache__/blockprocessors.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/markdown/__pycache__/core.cpython-312.pyc b/lib/python3.12/site-packages/markdown/__pycache__/core.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b27a474b0cecb153fe5575963b82e710bf0691ae Binary files /dev/null and b/lib/python3.12/site-packages/markdown/__pycache__/core.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/markdown/__pycache__/htmlparser.cpython-312.pyc b/lib/python3.12/site-packages/markdown/__pycache__/htmlparser.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..cc3e2b88cf2124fce549b42684811e131983fe56 Binary files /dev/null and b/lib/python3.12/site-packages/markdown/__pycache__/htmlparser.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/markdown/__pycache__/inlinepatterns.cpython-312.pyc b/lib/python3.12/site-packages/markdown/__pycache__/inlinepatterns.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8795abc7a01e2d4d0fdb5cdf3838721d13c75c9d Binary files /dev/null and b/lib/python3.12/site-packages/markdown/__pycache__/inlinepatterns.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/markdown/__pycache__/postprocessors.cpython-312.pyc b/lib/python3.12/site-packages/markdown/__pycache__/postprocessors.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..161a200a4138076f7803f9e7abbf7d2d94714b01 Binary files /dev/null and b/lib/python3.12/site-packages/markdown/__pycache__/postprocessors.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/markdown/__pycache__/preprocessors.cpython-312.pyc b/lib/python3.12/site-packages/markdown/__pycache__/preprocessors.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..02ec7dccf36dae24a9e4920639ff7df4ee2fef21 Binary files /dev/null and b/lib/python3.12/site-packages/markdown/__pycache__/preprocessors.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/markdown/__pycache__/serializers.cpython-312.pyc b/lib/python3.12/site-packages/markdown/__pycache__/serializers.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d5f876e015f614756d96d49d30ad85e77f635435 Binary files /dev/null and b/lib/python3.12/site-packages/markdown/__pycache__/serializers.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/markdown/__pycache__/test_tools.cpython-312.pyc b/lib/python3.12/site-packages/markdown/__pycache__/test_tools.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1f5558a2a0dcc72f5744c75f7de8bece2afb1ad4 Binary files /dev/null and b/lib/python3.12/site-packages/markdown/__pycache__/test_tools.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/markdown/__pycache__/treeprocessors.cpython-312.pyc b/lib/python3.12/site-packages/markdown/__pycache__/treeprocessors.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f1221b36ad4e0d26c615afd70bd2bfdba1b19c17 Binary files /dev/null and b/lib/python3.12/site-packages/markdown/__pycache__/treeprocessors.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/markdown/__pycache__/util.cpython-312.pyc b/lib/python3.12/site-packages/markdown/__pycache__/util.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..526ca5be044d37dd0bcd6f6000c461d7354616e7 Binary files /dev/null and b/lib/python3.12/site-packages/markdown/__pycache__/util.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/markdown/blockparser.py b/lib/python3.12/site-packages/markdown/blockparser.py new file mode 100644 index 0000000000000000000000000000000000000000..549c9ecd5a2ff01f84c2eccb53d537e18502a8fe --- /dev/null +++ b/lib/python3.12/site-packages/markdown/blockparser.py @@ -0,0 +1,160 @@ +# Python Markdown + +# A Python implementation of John Gruber's Markdown. + +# Documentation: https://python-markdown.github.io/ +# GitHub: https://github.com/Python-Markdown/markdown/ +# PyPI: https://pypi.org/project/Markdown/ + +# Started by Manfred Stienstra (http://www.dwerg.net/). +# Maintained for a few years by Yuri Takhteyev (http://www.freewisdom.org). +# Currently maintained by Waylan Limberg (https://github.com/waylan), +# Dmitry Shachnev (https://github.com/mitya57) and Isaac Muse (https://github.com/facelessuser). + +# Copyright 2007-2023 The Python Markdown Project (v. 1.7 and later) +# Copyright 2004, 2005, 2006 Yuri Takhteyev (v. 0.2-1.6b) +# Copyright 2004 Manfred Stienstra (the original version) + +# License: BSD (see LICENSE.md for details). + +""" +The block parser handles basic parsing of Markdown blocks. It doesn't concern +itself with inline elements such as `**bold**` or `*italics*`, but rather just +catches blocks, lists, quotes, etc. + +The `BlockParser` is made up of a bunch of `BlockProcessors`, each handling a +different type of block. Extensions may add/replace/remove `BlockProcessors` +as they need to alter how Markdown blocks are parsed. +""" + +from __future__ import annotations + +import xml.etree.ElementTree as etree +from typing import TYPE_CHECKING, Iterable, Any +from . import util + +if TYPE_CHECKING: # pragma: no cover + from markdown import Markdown + from .blockprocessors import BlockProcessor + + +class State(list): + """ Track the current and nested state of the parser. + + This utility class is used to track the state of the `BlockParser` and + support multiple levels if nesting. It's just a simple API wrapped around + a list. Each time a state is set, that state is appended to the end of the + list. Each time a state is reset, that state is removed from the end of + the list. + + Therefore, each time a state is set for a nested block, that state must be + reset when we back out of that level of nesting or the state could be + corrupted. + + While all the methods of a list object are available, only the three + defined below need be used. + + """ + + def set(self, state: Any): + """ Set a new state. """ + self.append(state) + + def reset(self) -> None: + """ Step back one step in nested state. """ + self.pop() + + def isstate(self, state: Any) -> bool: + """ Test that top (current) level is of given state. """ + if len(self): + return self[-1] == state + else: + return False + + +class BlockParser: + """ Parse Markdown blocks into an `ElementTree` object. + + A wrapper class that stitches the various `BlockProcessors` together, + looping through them and creating an `ElementTree` object. + + """ + + def __init__(self, md: Markdown): + """ Initialize the block parser. + + Arguments: + md: A Markdown instance. + + Attributes: + BlockParser.md (Markdown): A Markdown instance. + BlockParser.state (State): Tracks the nesting level of current location in document being parsed. + BlockParser.blockprocessors (util.Registry): A collection of + [`blockprocessors`][markdown.blockprocessors]. + + """ + self.blockprocessors: util.Registry[BlockProcessor] = util.Registry() + self.state = State() + self.md = md + + def parseDocument(self, lines: Iterable[str]) -> etree.ElementTree: + """ Parse a Markdown document into an `ElementTree`. + + Given a list of lines, an `ElementTree` object (not just a parent + `Element`) is created and the root element is passed to the parser + as the parent. The `ElementTree` object is returned. + + This should only be called on an entire document, not pieces. + + Arguments: + lines: A list of lines (strings). + + Returns: + An element tree. + """ + # Create an `ElementTree` from the lines + self.root = etree.Element(self.md.doc_tag) + self.parseChunk(self.root, '\n'.join(lines)) + return etree.ElementTree(self.root) + + def parseChunk(self, parent: etree.Element, text: str) -> None: + """ Parse a chunk of Markdown text and attach to given `etree` node. + + While the `text` argument is generally assumed to contain multiple + blocks which will be split on blank lines, it could contain only one + block. Generally, this method would be called by extensions when + block parsing is required. + + The `parent` `etree` Element passed in is altered in place. + Nothing is returned. + + Arguments: + parent: The parent element. + text: The text to parse. + + """ + self.parseBlocks(parent, text.split('\n\n')) + + def parseBlocks(self, parent: etree.Element, blocks: list[str]) -> None: + """ Process blocks of Markdown text and attach to given `etree` node. + + Given a list of `blocks`, each `blockprocessor` is stepped through + until there are no blocks left. While an extension could potentially + call this method directly, it's generally expected to be used + internally. + + This is a public method as an extension may need to add/alter + additional `BlockProcessors` which call this method to recursively + parse a nested block. + + Arguments: + parent: The parent element. + blocks: The blocks of text to parse. + + """ + while blocks: + for processor in self.blockprocessors: + if processor.test(parent, blocks[0]): + if processor.run(parent, blocks) is not False: + # run returns True or None + break diff --git a/lib/python3.12/site-packages/markdown/blockprocessors.py b/lib/python3.12/site-packages/markdown/blockprocessors.py new file mode 100644 index 0000000000000000000000000000000000000000..3ed4cf07fcf63852556939382e36bc2210e55e0d --- /dev/null +++ b/lib/python3.12/site-packages/markdown/blockprocessors.py @@ -0,0 +1,641 @@ +# Python Markdown + +# A Python implementation of John Gruber's Markdown. + +# Documentation: https://python-markdown.github.io/ +# GitHub: https://github.com/Python-Markdown/markdown/ +# PyPI: https://pypi.org/project/Markdown/ + +# Started by Manfred Stienstra (http://www.dwerg.net/). +# Maintained for a few years by Yuri Takhteyev (http://www.freewisdom.org). +# Currently maintained by Waylan Limberg (https://github.com/waylan), +# Dmitry Shachnev (https://github.com/mitya57) and Isaac Muse (https://github.com/facelessuser). + +# Copyright 2007-2023 The Python Markdown Project (v. 1.7 and later) +# Copyright 2004, 2005, 2006 Yuri Takhteyev (v. 0.2-1.6b) +# Copyright 2004 Manfred Stienstra (the original version) + +# License: BSD (see LICENSE.md for details). + +""" +A block processor parses blocks of text and adds new elements to the ElementTree. Blocks of text, +separated from other text by blank lines, may have a different syntax and produce a differently +structured tree than other Markdown. Block processors excel at handling code formatting, equation +layouts, tables, etc. +""" + +from __future__ import annotations + +import logging +import re +import xml.etree.ElementTree as etree +from typing import TYPE_CHECKING, Any +from . import util +from .blockparser import BlockParser + +if TYPE_CHECKING: # pragma: no cover + from markdown import Markdown + +logger = logging.getLogger('MARKDOWN') + + +def build_block_parser(md: Markdown, **kwargs: Any) -> BlockParser: + """ Build the default block parser used by Markdown. """ + parser = BlockParser(md) + parser.blockprocessors.register(EmptyBlockProcessor(parser), 'empty', 100) + parser.blockprocessors.register(ListIndentProcessor(parser), 'indent', 90) + parser.blockprocessors.register(CodeBlockProcessor(parser), 'code', 80) + parser.blockprocessors.register(HashHeaderProcessor(parser), 'hashheader', 70) + parser.blockprocessors.register(SetextHeaderProcessor(parser), 'setextheader', 60) + parser.blockprocessors.register(HRProcessor(parser), 'hr', 50) + parser.blockprocessors.register(OListProcessor(parser), 'olist', 40) + parser.blockprocessors.register(UListProcessor(parser), 'ulist', 30) + parser.blockprocessors.register(BlockQuoteProcessor(parser), 'quote', 20) + parser.blockprocessors.register(ReferenceProcessor(parser), 'reference', 15) + parser.blockprocessors.register(ParagraphProcessor(parser), 'paragraph', 10) + return parser + + +class BlockProcessor: + """ Base class for block processors. + + Each subclass will provide the methods below to work with the source and + tree. Each processor will need to define it's own `test` and `run` + methods. The `test` method should return True or False, to indicate + whether the current block should be processed by this processor. If the + test passes, the parser will call the processors `run` method. + + Attributes: + BlockProcessor.parser (BlockParser): The `BlockParser` instance this is attached to. + BlockProcessor.tab_length (int): The tab length set on the `Markdown` instance. + + """ + + def __init__(self, parser: BlockParser): + self.parser = parser + self.tab_length = parser.md.tab_length + + def lastChild(self, parent: etree.Element) -> etree.Element | None: + """ Return the last child of an `etree` element. """ + if len(parent): + return parent[-1] + else: + return None + + def detab(self, text: str, length: int | None = None) -> tuple[str, str]: + """ Remove a tab from the front of each line of the given text. """ + if length is None: + length = self.tab_length + newtext = [] + lines = text.split('\n') + for line in lines: + if line.startswith(' ' * length): + newtext.append(line[length:]) + elif not line.strip(): + newtext.append('') + else: + break + return '\n'.join(newtext), '\n'.join(lines[len(newtext):]) + + def looseDetab(self, text: str, level: int = 1) -> str: + """ Remove a tab from front of lines but allowing dedented lines. """ + lines = text.split('\n') + for i in range(len(lines)): + if lines[i].startswith(' '*self.tab_length*level): + lines[i] = lines[i][self.tab_length*level:] + return '\n'.join(lines) + + def test(self, parent: etree.Element, block: str) -> bool: + """ Test for block type. Must be overridden by subclasses. + + As the parser loops through processors, it will call the `test` + method on each to determine if the given block of text is of that + type. This method must return a boolean `True` or `False`. The + actual method of testing is left to the needs of that particular + block type. It could be as simple as `block.startswith(some_string)` + or a complex regular expression. As the block type may be different + depending on the parent of the block (i.e. inside a list), the parent + `etree` element is also provided and may be used as part of the test. + + Keyword arguments: + parent: An `etree` element which will be the parent of the block. + block: A block of text from the source which has been split at blank lines. + """ + pass # pragma: no cover + + def run(self, parent: etree.Element, blocks: list[str]) -> bool | None: + """ Run processor. Must be overridden by subclasses. + + When the parser determines the appropriate type of a block, the parser + will call the corresponding processor's `run` method. This method + should parse the individual lines of the block and append them to + the `etree`. + + Note that both the `parent` and `etree` keywords are pointers + to instances of the objects which should be edited in place. Each + processor must make changes to the existing objects as there is no + mechanism to return new/different objects to replace them. + + This means that this method should be adding `SubElements` or adding text + to the parent, and should remove (`pop`) or add (`insert`) items to + the list of blocks. + + If `False` is returned, this will have the same effect as returning `False` + from the `test` method. + + Keyword arguments: + parent: An `etree` element which is the parent of the current block. + blocks: A list of all remaining blocks of the document. + """ + pass # pragma: no cover + + +class ListIndentProcessor(BlockProcessor): + """ Process children of list items. + + Example + + * a list item + process this part + + or this part + + """ + + ITEM_TYPES = ['li'] + """ List of tags used for list items. """ + LIST_TYPES = ['ul', 'ol'] + """ Types of lists this processor can operate on. """ + + def __init__(self, *args): + super().__init__(*args) + self.INDENT_RE = re.compile(r'^(([ ]{%s})+)' % self.tab_length) + + def test(self, parent: etree.Element, block: str) -> bool: + return block.startswith(' '*self.tab_length) and \ + not self.parser.state.isstate('detabbed') and \ + (parent.tag in self.ITEM_TYPES or + (len(parent) and parent[-1] is not None and + (parent[-1].tag in self.LIST_TYPES))) + + def run(self, parent: etree.Element, blocks: list[str]) -> None: + block = blocks.pop(0) + level, sibling = self.get_level(parent, block) + block = self.looseDetab(block, level) + + self.parser.state.set('detabbed') + if parent.tag in self.ITEM_TYPES: + # It's possible that this parent has a `ul` or `ol` child list + # with a member. If that is the case, then that should be the + # parent. This is intended to catch the edge case of an indented + # list whose first member was parsed previous to this point + # see `OListProcessor` + if len(parent) and parent[-1].tag in self.LIST_TYPES: + self.parser.parseBlocks(parent[-1], [block]) + else: + # The parent is already a `li`. Just parse the child block. + self.parser.parseBlocks(parent, [block]) + elif sibling.tag in self.ITEM_TYPES: + # The sibling is a `li`. Use it as parent. + self.parser.parseBlocks(sibling, [block]) + elif len(sibling) and sibling[-1].tag in self.ITEM_TYPES: + # The parent is a list (`ol` or `ul`) which has children. + # Assume the last child `li` is the parent of this block. + if sibling[-1].text: + # If the parent `li` has text, that text needs to be moved to a `p` + # The `p` must be 'inserted' at beginning of list in the event + # that other children already exist i.e.; a nested sub-list. + p = etree.Element('p') + p.text = sibling[-1].text + sibling[-1].text = '' + sibling[-1].insert(0, p) + self.parser.parseChunk(sibling[-1], block) + else: + self.create_item(sibling, block) + self.parser.state.reset() + + def create_item(self, parent: etree.Element, block: str) -> None: + """ Create a new `li` and parse the block with it as the parent. """ + li = etree.SubElement(parent, 'li') + self.parser.parseBlocks(li, [block]) + + def get_level(self, parent: etree.Element, block: str) -> tuple[int, etree.Element]: + """ Get level of indentation based on list level. """ + # Get indent level + m = self.INDENT_RE.match(block) + if m: + indent_level = len(m.group(1))/self.tab_length + else: + indent_level = 0 + if self.parser.state.isstate('list'): + # We're in a tight-list - so we already are at correct parent. + level = 1 + else: + # We're in a loose-list - so we need to find parent. + level = 0 + # Step through children of tree to find matching indent level. + while indent_level > level: + child = self.lastChild(parent) + if (child is not None and + (child.tag in self.LIST_TYPES or child.tag in self.ITEM_TYPES)): + if child.tag in self.LIST_TYPES: + level += 1 + parent = child + else: + # No more child levels. If we're short of `indent_level`, + # we have a code block. So we stop here. + break + return level, parent + + +class CodeBlockProcessor(BlockProcessor): + """ Process code blocks. """ + + def test(self, parent: etree.Element, block: str) -> bool: + return block.startswith(' '*self.tab_length) + + def run(self, parent: etree.Element, blocks: list[str]) -> None: + sibling = self.lastChild(parent) + block = blocks.pop(0) + theRest = '' + if (sibling is not None and sibling.tag == "pre" and + len(sibling) and sibling[0].tag == "code"): + # The previous block was a code block. As blank lines do not start + # new code blocks, append this block to the previous, adding back + # line breaks removed from the split into a list. + code = sibling[0] + block, theRest = self.detab(block) + code.text = util.AtomicString( + '{}\n{}\n'.format(code.text, util.code_escape(block.rstrip())) + ) + else: + # This is a new code block. Create the elements and insert text. + pre = etree.SubElement(parent, 'pre') + code = etree.SubElement(pre, 'code') + block, theRest = self.detab(block) + code.text = util.AtomicString('%s\n' % util.code_escape(block.rstrip())) + if theRest: + # This block contained unindented line(s) after the first indented + # line. Insert these lines as the first block of the master blocks + # list for future processing. + blocks.insert(0, theRest) + + +class BlockQuoteProcessor(BlockProcessor): + """ Process blockquotes. """ + + RE = re.compile(r'(^|\n)[ ]{0,3}>[ ]?(.*)') + + def test(self, parent: etree.Element, block: str) -> bool: + return bool(self.RE.search(block)) and not util.nearing_recursion_limit() + + def run(self, parent: etree.Element, blocks: list[str]) -> None: + block = blocks.pop(0) + m = self.RE.search(block) + if m: + before = block[:m.start()] # Lines before blockquote + # Pass lines before blockquote in recursively for parsing first. + self.parser.parseBlocks(parent, [before]) + # Remove `> ` from beginning of each line. + block = '\n'.join( + [self.clean(line) for line in block[m.start():].split('\n')] + ) + sibling = self.lastChild(parent) + if sibling is not None and sibling.tag == "blockquote": + # Previous block was a blockquote so set that as this blocks parent + quote = sibling + else: + # This is a new blockquote. Create a new parent element. + quote = etree.SubElement(parent, 'blockquote') + # Recursively parse block with blockquote as parent. + # change parser state so blockquotes embedded in lists use `p` tags + self.parser.state.set('blockquote') + self.parser.parseChunk(quote, block) + self.parser.state.reset() + + def clean(self, line: str) -> str: + """ Remove `>` from beginning of a line. """ + m = self.RE.match(line) + if line.strip() == ">": + return "" + elif m: + return m.group(2) + else: + return line + + +class OListProcessor(BlockProcessor): + """ Process ordered list blocks. """ + + TAG: str = 'ol' + """ The tag used for the the wrapping element. """ + STARTSWITH: str = '1' + """ + The integer (as a string ) with which the list starts. For example, if a list is initialized as + `3. Item`, then the `ol` tag will be assigned an HTML attribute of `starts="3"`. Default: `"1"`. + """ + LAZY_OL: bool = True + """ Ignore `STARTSWITH` if `True`. """ + SIBLING_TAGS: list[str] = ['ol', 'ul'] + """ + Markdown does not require the type of a new list item match the previous list item type. + This is the list of types which can be mixed. + """ + + def __init__(self, parser: BlockParser): + super().__init__(parser) + # Detect an item (`1. item`). `group(1)` contains contents of item. + self.RE = re.compile(r'^[ ]{0,%d}\d+\.[ ]+(.*)' % (self.tab_length - 1)) + # Detect items on secondary lines. they can be of either list type. + self.CHILD_RE = re.compile(r'^[ ]{0,%d}((\d+\.)|[*+-])[ ]+(.*)' % + (self.tab_length - 1)) + # Detect indented (nested) items of either type + self.INDENT_RE = re.compile(r'^[ ]{%d,%d}((\d+\.)|[*+-])[ ]+.*' % + (self.tab_length, self.tab_length * 2 - 1)) + + def test(self, parent: etree.Element, block: str) -> bool: + return bool(self.RE.match(block)) + + def run(self, parent: etree.Element, blocks: list[str]) -> None: + # Check for multiple items in one block. + items = self.get_items(blocks.pop(0)) + sibling = self.lastChild(parent) + + if sibling is not None and sibling.tag in self.SIBLING_TAGS: + # Previous block was a list item, so set that as parent + lst = sibling + # make sure previous item is in a `p` - if the item has text, + # then it isn't in a `p` + if lst[-1].text: + # since it's possible there are other children for this + # sibling, we can't just `SubElement` the `p`, we need to + # insert it as the first item. + p = etree.Element('p') + p.text = lst[-1].text + lst[-1].text = '' + lst[-1].insert(0, p) + # if the last item has a tail, then the tail needs to be put in a `p` + # likely only when a header is not followed by a blank line + lch = self.lastChild(lst[-1]) + if lch is not None and lch.tail: + p = etree.SubElement(lst[-1], 'p') + p.text = lch.tail.lstrip() + lch.tail = '' + + # parse first block differently as it gets wrapped in a `p`. + li = etree.SubElement(lst, 'li') + self.parser.state.set('looselist') + firstitem = items.pop(0) + self.parser.parseBlocks(li, [firstitem]) + self.parser.state.reset() + elif parent.tag in ['ol', 'ul']: + # this catches the edge case of a multi-item indented list whose + # first item is in a blank parent-list item: + # * * subitem1 + # * subitem2 + # see also `ListIndentProcessor` + lst = parent + else: + # This is a new list so create parent with appropriate tag. + lst = etree.SubElement(parent, self.TAG) + # Check if a custom start integer is set + if not self.LAZY_OL and self.STARTSWITH != '1': + lst.attrib['start'] = self.STARTSWITH + + self.parser.state.set('list') + # Loop through items in block, recursively parsing each with the + # appropriate parent. + for item in items: + if item.startswith(' '*self.tab_length): + # Item is indented. Parse with last item as parent + self.parser.parseBlocks(lst[-1], [item]) + else: + # New item. Create `li` and parse with it as parent + li = etree.SubElement(lst, 'li') + self.parser.parseBlocks(li, [item]) + self.parser.state.reset() + + def get_items(self, block: str) -> list[str]: + """ Break a block into list items. """ + items = [] + for line in block.split('\n'): + m = self.CHILD_RE.match(line) + if m: + # This is a new list item + # Check first item for the start index + if not items and self.TAG == 'ol': + # Detect the integer value of first list item + INTEGER_RE = re.compile(r'(\d+)') + self.STARTSWITH = INTEGER_RE.match(m.group(1)).group() + # Append to the list + items.append(m.group(3)) + elif self.INDENT_RE.match(line): + # This is an indented (possibly nested) item. + if items[-1].startswith(' '*self.tab_length): + # Previous item was indented. Append to that item. + items[-1] = '{}\n{}'.format(items[-1], line) + else: + items.append(line) + else: + # This is another line of previous item. Append to that item. + items[-1] = '{}\n{}'.format(items[-1], line) + return items + + +class UListProcessor(OListProcessor): + """ Process unordered list blocks. """ + + TAG: str = 'ul' + """ The tag used for the the wrapping element. """ + + def __init__(self, parser: BlockParser): + super().__init__(parser) + # Detect an item (`1. item`). `group(1)` contains contents of item. + self.RE = re.compile(r'^[ ]{0,%d}[*+-][ ]+(.*)' % (self.tab_length - 1)) + + +class HashHeaderProcessor(BlockProcessor): + """ Process Hash Headers. """ + + # Detect a header at start of any line in block + RE = re.compile(r'(?:^|\n)(?P#{1,6})(?P
(?:\\.|[^\\])*?)#*(?:\n|$)') + + def test(self, parent: etree.Element, block: str) -> bool: + return bool(self.RE.search(block)) + + def run(self, parent: etree.Element, blocks: list[str]) -> None: + block = blocks.pop(0) + m = self.RE.search(block) + if m: + before = block[:m.start()] # All lines before header + after = block[m.end():] # All lines after header + if before: + # As the header was not the first line of the block and the + # lines before the header must be parsed first, + # recursively parse this lines as a block. + self.parser.parseBlocks(parent, [before]) + # Create header using named groups from RE + h = etree.SubElement(parent, 'h%d' % len(m.group('level'))) + h.text = m.group('header').strip() + if after: + # Insert remaining lines as first block for future parsing. + if self.parser.state.isstate('looselist'): + # This is a weird edge case where a header is a child of a loose list + # and there is no blank line after the header. To ensure proper + # parsing, the line(s) after need to be detabbed. See #1443. + after = self.looseDetab(after) + blocks.insert(0, after) + else: # pragma: no cover + # This should never happen, but just in case... + logger.warn("We've got a problem header: %r" % block) + + +class SetextHeaderProcessor(BlockProcessor): + """ Process Setext-style Headers. """ + + # Detect Setext-style header. Must be first 2 lines of block. + RE = re.compile(r'^.*?\n[=-]+[ ]*(\n|$)', re.MULTILINE) + + def test(self, parent: etree.Element, block: str) -> bool: + return bool(self.RE.match(block)) + + def run(self, parent: etree.Element, blocks: list[str]) -> None: + lines = blocks.pop(0).split('\n') + # Determine level. `=` is 1 and `-` is 2. + if lines[1].startswith('='): + level = 1 + else: + level = 2 + h = etree.SubElement(parent, 'h%d' % level) + h.text = lines[0].strip() + if len(lines) > 2: + # Block contains additional lines. Add to master blocks for later. + blocks.insert(0, '\n'.join(lines[2:])) + + +class HRProcessor(BlockProcessor): + """ Process Horizontal Rules. """ + + # Python's `re` module doesn't officially support atomic grouping. However you can fake it. + # See https://stackoverflow.com/a/13577411/866026 + RE = r'^[ ]{0,3}(?=(?P(-+[ ]{0,2}){3,}|(_+[ ]{0,2}){3,}|(\*+[ ]{0,2}){3,}))(?P=atomicgroup)[ ]*$' + # Detect hr on any line of a block. + SEARCH_RE = re.compile(RE, re.MULTILINE) + + def test(self, parent: etree.Element, block: str) -> bool: + m = self.SEARCH_RE.search(block) + if m: + # Save match object on class instance so we can use it later. + self.match = m + return True + return False + + def run(self, parent: etree.Element, blocks: list[str]) -> None: + block = blocks.pop(0) + match = self.match + # Check for lines in block before `hr`. + prelines = block[:match.start()].rstrip('\n') + if prelines: + # Recursively parse lines before `hr` so they get parsed first. + self.parser.parseBlocks(parent, [prelines]) + # create hr + etree.SubElement(parent, 'hr') + # check for lines in block after `hr`. + postlines = block[match.end():].lstrip('\n') + if postlines: + # Add lines after `hr` to master blocks for later parsing. + blocks.insert(0, postlines) + + +class EmptyBlockProcessor(BlockProcessor): + """ Process blocks that are empty or start with an empty line. """ + + def test(self, parent: etree.Element, block: str) -> bool: + return not block or block.startswith('\n') + + def run(self, parent: etree.Element, blocks: list[str]) -> None: + block = blocks.pop(0) + filler = '\n\n' + if block: + # Starts with empty line + # Only replace a single line. + filler = '\n' + # Save the rest for later. + theRest = block[1:] + if theRest: + # Add remaining lines to master blocks for later. + blocks.insert(0, theRest) + sibling = self.lastChild(parent) + if (sibling is not None and sibling.tag == 'pre' and + len(sibling) and sibling[0].tag == 'code'): + # Last block is a code block. Append to preserve whitespace. + sibling[0].text = util.AtomicString( + '{}{}'.format(sibling[0].text, filler) + ) + + +class ReferenceProcessor(BlockProcessor): + """ Process link references. """ + RE = re.compile( + r'^[ ]{0,3}\[([^\[\]]*)\]:[ ]*\n?[ ]*([^\s]+)[ ]*(?:\n[ ]*)?((["\'])(.*)\4[ ]*|\((.*)\)[ ]*)?$', re.MULTILINE + ) + + def test(self, parent: etree.Element, block: str) -> bool: + return True + + def run(self, parent: etree.Element, blocks: list[str]) -> bool: + block = blocks.pop(0) + m = self.RE.search(block) + if m: + id = m.group(1).strip().lower() + link = m.group(2).lstrip('<').rstrip('>') + title = m.group(5) or m.group(6) + self.parser.md.references[id] = (link, title) + if block[m.end():].strip(): + # Add any content after match back to blocks as separate block + blocks.insert(0, block[m.end():].lstrip('\n')) + if block[:m.start()].strip(): + # Add any content before match back to blocks as separate block + blocks.insert(0, block[:m.start()].rstrip('\n')) + return True + # No match. Restore block. + blocks.insert(0, block) + return False + + +class ParagraphProcessor(BlockProcessor): + """ Process Paragraph blocks. """ + + def test(self, parent: etree.Element, block: str) -> bool: + return True + + def run(self, parent: etree.Element, blocks: list[str]) -> None: + block = blocks.pop(0) + if block.strip(): + # Not a blank block. Add to parent, otherwise throw it away. + if self.parser.state.isstate('list'): + # The parent is a tight-list. + # + # Check for any children. This will likely only happen in a + # tight-list when a header isn't followed by a blank line. + # For example: + # + # * # Header + # Line 2 of list item - not part of header. + sibling = self.lastChild(parent) + if sibling is not None: + # Insert after sibling. + if sibling.tail: + sibling.tail = '{}\n{}'.format(sibling.tail, block) + else: + sibling.tail = '\n%s' % block + else: + # Append to parent.text + if parent.text: + parent.text = '{}\n{}'.format(parent.text, block) + else: + parent.text = block.lstrip() + else: + # Create a regular paragraph + p = etree.SubElement(parent, 'p') + p.text = block.lstrip() diff --git a/lib/python3.12/site-packages/markdown/core.py b/lib/python3.12/site-packages/markdown/core.py new file mode 100644 index 0000000000000000000000000000000000000000..11cb5adc90dca19a2f2640670278561ed76eae3a --- /dev/null +++ b/lib/python3.12/site-packages/markdown/core.py @@ -0,0 +1,503 @@ +# Python Markdown + +# A Python implementation of John Gruber's Markdown. + +# Documentation: https://python-markdown.github.io/ +# GitHub: https://github.com/Python-Markdown/markdown/ +# PyPI: https://pypi.org/project/Markdown/ + +# Started by Manfred Stienstra (http://www.dwerg.net/). +# Maintained for a few years by Yuri Takhteyev (http://www.freewisdom.org). +# Currently maintained by Waylan Limberg (https://github.com/waylan), +# Dmitry Shachnev (https://github.com/mitya57) and Isaac Muse (https://github.com/facelessuser). + +# Copyright 2007-2023 The Python Markdown Project (v. 1.7 and later) +# Copyright 2004, 2005, 2006 Yuri Takhteyev (v. 0.2-1.6b) +# Copyright 2004 Manfred Stienstra (the original version) + +# License: BSD (see LICENSE.md for details). + +from __future__ import annotations + +import codecs +import sys +import logging +import importlib +from typing import TYPE_CHECKING, Any, BinaryIO, Callable, ClassVar, Mapping, Sequence +from . import util +from .preprocessors import build_preprocessors +from .blockprocessors import build_block_parser +from .treeprocessors import build_treeprocessors +from .inlinepatterns import build_inlinepatterns +from .postprocessors import build_postprocessors +from .extensions import Extension +from .serializers import to_html_string, to_xhtml_string +from .util import BLOCK_LEVEL_ELEMENTS + +if TYPE_CHECKING: # pragma: no cover + from xml.etree.ElementTree import Element + +__all__ = ['Markdown', 'markdown', 'markdownFromFile'] + + +logger = logging.getLogger('MARKDOWN') + + +class Markdown: + """ + A parser which converts Markdown to HTML. + + Attributes: + Markdown.tab_length (int): The number of spaces which correspond to a single tab. Default: `4`. + Markdown.ESCAPED_CHARS (list[str]): List of characters which get the backslash escape treatment. + Markdown.block_level_elements (list[str]): List of HTML tags which get treated as block-level elements. + See [`markdown.util.BLOCK_LEVEL_ELEMENTS`][] for the full list of elements. + Markdown.registeredExtensions (list[Extension]): List of extensions which have called + [`registerExtension`][markdown.Markdown.registerExtension] during setup. + Markdown.doc_tag (str): Element used to wrap document. Default: `div`. + Markdown.stripTopLevelTags (bool): Indicates whether the `doc_tag` should be removed. Default: 'True'. + Markdown.references (dict[str, tuple[str, str]]): A mapping of link references found in a parsed document + where the key is the reference name and the value is a tuple of the URL and title. + Markdown.htmlStash (util.HtmlStash): The instance of the `HtmlStash` used by an instance of this class. + Markdown.output_formats (dict[str, Callable[xml.etree.ElementTree.Element]]): A mapping of known output + formats by name and their respective serializers. Each serializer must be a callable which accepts an + [`Element`][xml.etree.ElementTree.Element] and returns a `str`. + Markdown.output_format (str): The output format set by + [`set_output_format`][markdown.Markdown.set_output_format]. + Markdown.serializer (Callable[xml.etree.ElementTree.Element]): The serializer set by + [`set_output_format`][markdown.Markdown.set_output_format]. + Markdown.preprocessors (util.Registry): A collection of [`preprocessors`][markdown.preprocessors]. + Markdown.parser (blockparser.BlockParser): A collection of [`blockprocessors`][markdown.blockprocessors]. + Markdown.inlinePatterns (util.Registry): A collection of [`inlinepatterns`][markdown.inlinepatterns]. + Markdown.treeprocessors (util.Registry): A collection of [`treeprocessors`][markdown.treeprocessors]. + Markdown.postprocessors (util.Registry): A collection of [`postprocessors`][markdown.postprocessors]. + + """ + + doc_tag = "div" # Element used to wrap document - later removed + + output_formats: ClassVar[dict[str, Callable[[Element], str]]] = { + 'html': to_html_string, + 'xhtml': to_xhtml_string, + } + """ + A mapping of known output formats by name and their respective serializers. Each serializer must be a + callable which accepts an [`Element`][xml.etree.ElementTree.Element] and returns a `str`. + """ + + def __init__(self, **kwargs): + """ + Creates a new Markdown instance. + + Keyword Arguments: + extensions (list[Extension | str]): A list of extensions. + + If an item is an instance of a subclass of [`markdown.extensions.Extension`][], + the instance will be used as-is. If an item is of type `str`, it is passed + to [`build_extension`][markdown.Markdown.build_extension] with its corresponding + `extension_configs` and the returned instance of [`markdown.extensions.Extension`][] + is used. + extension_configs (dict[str, dict[str, Any]]): Configuration settings for extensions. + output_format (str): Format of output. Supported formats are: + + * `xhtml`: Outputs XHTML style tags. Default. + * `html`: Outputs HTML style tags. + tab_length (int): Length of tabs in the source. Default: `4` + + """ + + self.tab_length: int = kwargs.get('tab_length', 4) + + self.ESCAPED_CHARS: list[str] = [ + '\\', '`', '*', '_', '{', '}', '[', ']', '(', ')', '>', '#', '+', '-', '.', '!' + ] + """ List of characters which get the backslash escape treatment. """ + + self.block_level_elements: list[str] = BLOCK_LEVEL_ELEMENTS.copy() + + self.registeredExtensions: list[Extension] = [] + self.docType = "" # TODO: Maybe delete this. It does not appear to be used anymore. + self.stripTopLevelTags: bool = True + + self.build_parser() + + self.references: dict[str, tuple[str, str]] = {} + self.htmlStash: util.HtmlStash = util.HtmlStash() + self.registerExtensions(extensions=kwargs.get('extensions', []), + configs=kwargs.get('extension_configs', {})) + self.set_output_format(kwargs.get('output_format', 'xhtml')) + self.reset() + + def build_parser(self) -> Markdown: + """ + Build the parser from the various parts. + + Assigns a value to each of the following attributes on the class instance: + + * **`Markdown.preprocessors`** ([`Registry`][markdown.util.Registry]) -- A collection of + [`preprocessors`][markdown.preprocessors]. + * **`Markdown.parser`** ([`BlockParser`][markdown.blockparser.BlockParser]) -- A collection of + [`blockprocessors`][markdown.blockprocessors]. + * **`Markdown.inlinePatterns`** ([`Registry`][markdown.util.Registry]) -- A collection of + [`inlinepatterns`][markdown.inlinepatterns]. + * **`Markdown.treeprocessors`** ([`Registry`][markdown.util.Registry]) -- A collection of + [`treeprocessors`][markdown.treeprocessors]. + * **`Markdown.postprocessors`** ([`Registry`][markdown.util.Registry]) -- A collection of + [`postprocessors`][markdown.postprocessors]. + + This method could be redefined in a subclass to build a custom parser which is made up of a different + combination of processors and patterns. + + """ + self.preprocessors = build_preprocessors(self) + self.parser = build_block_parser(self) + self.inlinePatterns = build_inlinepatterns(self) + self.treeprocessors = build_treeprocessors(self) + self.postprocessors = build_postprocessors(self) + return self + + def registerExtensions( + self, + extensions: Sequence[Extension | str], + configs: Mapping[str, dict[str, Any]] + ) -> Markdown: + """ + Load a list of extensions into an instance of the `Markdown` class. + + Arguments: + extensions (list[Extension | str]): A list of extensions. + + If an item is an instance of a subclass of [`markdown.extensions.Extension`][], + the instance will be used as-is. If an item is of type `str`, it is passed + to [`build_extension`][markdown.Markdown.build_extension] with its corresponding `configs` and the + returned instance of [`markdown.extensions.Extension`][] is used. + configs (dict[str, dict[str, Any]]): Configuration settings for extensions. + + """ + for ext in extensions: + if isinstance(ext, str): + ext = self.build_extension(ext, configs.get(ext, {})) + if isinstance(ext, Extension): + ext.extendMarkdown(self) + logger.debug( + 'Successfully loaded extension "%s.%s".' + % (ext.__class__.__module__, ext.__class__.__name__) + ) + elif ext is not None: + raise TypeError( + 'Extension "{}.{}" must be of type: "{}.{}"'.format( + ext.__class__.__module__, ext.__class__.__name__, + Extension.__module__, Extension.__name__ + ) + ) + return self + + def build_extension(self, ext_name: str, configs: Mapping[str, Any]) -> Extension: + """ + Build extension from a string name, then return an instance using the given `configs`. + + Arguments: + ext_name: Name of extension as a string. + configs: Configuration settings for extension. + + Returns: + An instance of the extension with the given configuration settings. + + First attempt to load an entry point. The string name must be registered as an entry point in the + `markdown.extensions` group which points to a subclass of the [`markdown.extensions.Extension`][] class. + If multiple distributions have registered the same name, the first one found is returned. + + If no entry point is found, assume dot notation (`path.to.module:ClassName`). Load the specified class and + return an instance. If no class is specified, import the module and call a `makeExtension` function and return + the [`markdown.extensions.Extension`][] instance returned by that function. + """ + configs = dict(configs) + + entry_points = [ep for ep in util.get_installed_extensions() if ep.name == ext_name] + if entry_points: + ext = entry_points[0].load() + return ext(**configs) + + # Get class name (if provided): `path.to.module:ClassName` + ext_name, class_name = ext_name.split(':', 1) if ':' in ext_name else (ext_name, '') + + try: + module = importlib.import_module(ext_name) + logger.debug( + 'Successfully imported extension module "%s".' % ext_name + ) + except ImportError as e: + message = 'Failed loading extension "%s".' % ext_name + e.args = (message,) + e.args[1:] + raise + + if class_name: + # Load given class name from module. + return getattr(module, class_name)(**configs) + else: + # Expect `makeExtension()` function to return a class. + try: + return module.makeExtension(**configs) + except AttributeError as e: + message = e.args[0] + message = "Failed to initiate extension " \ + "'%s': %s" % (ext_name, message) + e.args = (message,) + e.args[1:] + raise + + def registerExtension(self, extension: Extension) -> Markdown: + """ + Register an extension as having a resettable state. + + Arguments: + extension: An instance of the extension to register. + + This should get called once by an extension during setup. A "registered" extension's + `reset` method is called by [`Markdown.reset()`][markdown.Markdown.reset]. Not all extensions have or need a + resettable state, and so it should not be assumed that all extensions are "registered." + + """ + self.registeredExtensions.append(extension) + return self + + def reset(self) -> Markdown: + """ + Resets all state variables to prepare the parser instance for new input. + + Called once upon creation of a class instance. Should be called manually between calls + to [`Markdown.convert`][markdown.Markdown.convert]. + """ + self.htmlStash.reset() + self.references.clear() + + for extension in self.registeredExtensions: + if hasattr(extension, 'reset'): + extension.reset() + + return self + + def set_output_format(self, format: str) -> Markdown: + """ + Set the output format for the class instance. + + Arguments: + format: Must be a known value in `Markdown.output_formats`. + + """ + self.output_format = format.lower().rstrip('145') # ignore number + try: + self.serializer = self.output_formats[self.output_format] + except KeyError as e: + valid_formats = list(self.output_formats.keys()) + valid_formats.sort() + message = 'Invalid Output Format: "%s". Use one of %s.' \ + % (self.output_format, + '"' + '", "'.join(valid_formats) + '"') + e.args = (message,) + e.args[1:] + raise + return self + + # Note: the `tag` argument is type annotated `Any` as ElementTree uses many various objects as tags. + # As there is no standardization in ElementTree, the type of a given tag is unpredictable. + def is_block_level(self, tag: Any) -> bool: + """ + Check if the given `tag` is a block level HTML tag. + + Returns `True` for any string listed in `Markdown.block_level_elements`. A `tag` which is + not a string always returns `False`. + + """ + if isinstance(tag, str): + return tag.lower().rstrip('/') in self.block_level_elements + # Some ElementTree tags are not strings, so return False. + return False + + def convert(self, source: str) -> str: + """ + Convert a Markdown string to a string in the specified output format. + + Arguments: + source: Markdown formatted text as Unicode or ASCII string. + + Returns: + A string in the specified output format. + + Markdown parsing takes place in five steps: + + 1. A bunch of [`preprocessors`][markdown.preprocessors] munge the input text. + 2. A [`BlockParser`][markdown.blockparser.BlockParser] parses the high-level structural elements of the + pre-processed text into an [`ElementTree`][xml.etree.ElementTree.ElementTree] object. + 3. A bunch of [`treeprocessors`][markdown.treeprocessors] are run against the + [`ElementTree`][xml.etree.ElementTree.ElementTree] object. One such `treeprocessor` + ([`markdown.treeprocessors.InlineProcessor`][]) runs [`inlinepatterns`][markdown.inlinepatterns] + against the [`ElementTree`][xml.etree.ElementTree.ElementTree] object, parsing inline markup. + 4. Some [`postprocessors`][markdown.postprocessors] are run against the text after the + [`ElementTree`][xml.etree.ElementTree.ElementTree] object has been serialized into text. + 5. The output is returned as a string. + + """ + + # Fix up the source text + if not source.strip(): + return '' # a blank Unicode string + + try: + source = str(source) + except UnicodeDecodeError as e: # pragma: no cover + # Customize error message while maintaining original traceback + e.reason += '. -- Note: Markdown only accepts Unicode input!' + raise + + # Split into lines and run the line preprocessors. + self.lines = source.split("\n") + for prep in self.preprocessors: + self.lines = prep.run(self.lines) + + # Parse the high-level elements. + root = self.parser.parseDocument(self.lines).getroot() + + # Run the tree-processors + for treeprocessor in self.treeprocessors: + newRoot = treeprocessor.run(root) + if newRoot is not None: + root = newRoot + + # Serialize _properly_. Strip top-level tags. + output = self.serializer(root) + if self.stripTopLevelTags: + try: + start = output.index( + '<%s>' % self.doc_tag) + len(self.doc_tag) + 2 + end = output.rindex('' % self.doc_tag) + output = output[start:end].strip() + except ValueError as e: # pragma: no cover + if output.strip().endswith('<%s />' % self.doc_tag): + # We have an empty document + output = '' + else: + # We have a serious problem + raise ValueError('Markdown failed to strip top-level ' + 'tags. Document=%r' % output.strip()) from e + + # Run the text post-processors + for pp in self.postprocessors: + output = pp.run(output) + + return output.strip() + + def convertFile( + self, + input: str | BinaryIO | None = None, + output: str | BinaryIO | None = None, + encoding: str | None = None, + ) -> Markdown: + """ + Converts a Markdown file and returns the HTML as a Unicode string. + + Decodes the file using the provided encoding (defaults to `utf-8`), + passes the file content to markdown, and outputs the HTML to either + the provided stream or the file with provided name, using the same + encoding as the source file. The + [`xmlcharrefreplace`](https://docs.python.org/3/library/codecs.html#error-handlers) + error handler is used when encoding the output. + + **Note:** This is the only place that decoding and encoding of Unicode + takes place in Python-Markdown. (All other code is Unicode-in / + Unicode-out.) + + Arguments: + input: File object or path. Reads from `stdin` if `None`. + output: File object or path. Writes to `stdout` if `None`. + encoding: Encoding of input and output files. Defaults to `utf-8`. + + """ + + encoding = encoding or "utf-8" + + # Read the source + if input: + if isinstance(input, str): + input_file = open(input, mode="r", encoding=encoding) + else: + input_file = codecs.getreader(encoding)(input) + text = input_file.read() + input_file.close() + else: + text = sys.stdin.read() + + text = text.lstrip('\ufeff') # remove the byte-order mark + + # Convert + html = self.convert(text) + + # Write to file or stdout + if output: + if isinstance(output, str): + output_file = codecs.open(output, "w", + encoding=encoding, + errors="xmlcharrefreplace") + output_file.write(html) + output_file.close() + else: + writer = codecs.getwriter(encoding) + output_file = writer(output, errors="xmlcharrefreplace") + output_file.write(html) + # Don't close here. User may want to write more. + else: + # Encode manually and write bytes to stdout. + html = html.encode(encoding, "xmlcharrefreplace") + sys.stdout.buffer.write(html) + + return self + + +""" +EXPORTED FUNCTIONS +============================================================================= + +Those are the two functions we really mean to export: `markdown()` and +`markdownFromFile()`. +""" + + +def markdown(text: str, **kwargs: Any) -> str: + """ + Convert a markdown string to HTML and return HTML as a Unicode string. + + This is a shortcut function for [`Markdown`][markdown.Markdown] class to cover the most + basic use case. It initializes an instance of [`Markdown`][markdown.Markdown], loads the + necessary extensions and runs the parser on the given text. + + Arguments: + text: Markdown formatted text as Unicode or ASCII string. + + Keyword arguments: + **kwargs: Any arguments accepted by the Markdown class. + + Returns: + A string in the specified output format. + + """ + md = Markdown(**kwargs) + return md.convert(text) + + +def markdownFromFile(**kwargs: Any): + """ + Read Markdown text from a file and write output to a file or a stream. + + This is a shortcut function which initializes an instance of [`Markdown`][markdown.Markdown], + and calls the [`convertFile`][markdown.Markdown.convertFile] method rather than + [`convert`][markdown.Markdown.convert]. + + Keyword arguments: + input (str | BinaryIO): A file name or readable object. + output (str | BinaryIO): A file name or writable object. + encoding (str): Encoding of input and output. + **kwargs: Any arguments accepted by the `Markdown` class. + + """ + md = Markdown(**kwargs) + md.convertFile(kwargs.get('input', None), + kwargs.get('output', None), + kwargs.get('encoding', None)) diff --git a/lib/python3.12/site-packages/markdown/extensions/__init__.py b/lib/python3.12/site-packages/markdown/extensions/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..a5ec07b27158129e61cca3ea6bbbf236f8d74927 --- /dev/null +++ b/lib/python3.12/site-packages/markdown/extensions/__init__.py @@ -0,0 +1,145 @@ +# Python Markdown + +# A Python implementation of John Gruber's Markdown. + +# Documentation: https://python-markdown.github.io/ +# GitHub: https://github.com/Python-Markdown/markdown/ +# PyPI: https://pypi.org/project/Markdown/ + +# Started by Manfred Stienstra (http://www.dwerg.net/). +# Maintained for a few years by Yuri Takhteyev (http://www.freewisdom.org). +# Currently maintained by Waylan Limberg (https://github.com/waylan), +# Dmitry Shachnev (https://github.com/mitya57) and Isaac Muse (https://github.com/facelessuser). + +# Copyright 2007-2023 The Python Markdown Project (v. 1.7 and later) +# Copyright 2004, 2005, 2006 Yuri Takhteyev (v. 0.2-1.6b) +# Copyright 2004 Manfred Stienstra (the original version) + +# License: BSD (see LICENSE.md for details). + +""" +Markdown accepts an [`Extension`][markdown.extensions.Extension] instance for each extension. Therefore, each extension +must to define a class that extends [`Extension`][markdown.extensions.Extension] and over-rides the +[`extendMarkdown`][markdown.extensions.Extension.extendMarkdown] method. Within this class one can manage configuration +options for their extension and attach the various processors and patterns which make up an extension to the +[`Markdown`][markdown.Markdown] instance. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Iterable, Mapping +from ..util import parseBoolValue + +if TYPE_CHECKING: # pragma: no cover + from markdown import Markdown + + +class Extension: + """ Base class for extensions to subclass. """ + + config: Mapping[str, list] = {} + """ + Default configuration for an extension. + + This attribute is to be defined in a subclass and must be of the following format: + + ``` python + config = { + 'key': ['value', 'description'] + } + ``` + + Note that [`setConfig`][markdown.extensions.Extension.setConfig] will raise a [`KeyError`][] + if a default is not set for each option. + """ + + def __init__(self, **kwargs): + """ Initiate Extension and set up configs. """ + self.setConfigs(kwargs) + + def getConfig(self, key: str, default: Any = '') -> Any: + """ + Return a single configuration option value. + + Arguments: + key: The configuration option name. + default: Default value to return if key is not set. + + Returns: + Value of stored configuration option. + """ + if key in self.config: + return self.config[key][0] + else: + return default + + def getConfigs(self) -> dict[str, Any]: + """ + Return all configuration options. + + Returns: + All configuration options. + """ + return {key: self.getConfig(key) for key in self.config.keys()} + + def getConfigInfo(self) -> list[tuple[str, str]]: + """ + Return descriptions of all configuration options. + + Returns: + All descriptions of configuration options. + """ + return [(key, self.config[key][1]) for key in self.config.keys()] + + def setConfig(self, key: str, value: Any) -> None: + """ + Set a configuration option. + + If the corresponding default value set in [`config`][markdown.extensions.Extension.config] + is a `bool` value or `None`, then `value` is passed through + [`parseBoolValue`][markdown.util.parseBoolValue] before being stored. + + Arguments: + key: Name of configuration option to set. + value: Value to assign to option. + + Raises: + KeyError: If `key` is not known. + """ + if isinstance(self.config[key][0], bool): + value = parseBoolValue(value) + if self.config[key][0] is None: + value = parseBoolValue(value, preserve_none=True) + self.config[key][0] = value + + def setConfigs(self, items: Mapping[str, Any] | Iterable[tuple[str, Any]]) -> None: + """ + Loop through a collection of configuration options, passing each to + [`setConfig`][markdown.extensions.Extension.setConfig]. + + Arguments: + items: Collection of configuration options. + + Raises: + KeyError: for any unknown key. + """ + if hasattr(items, 'items'): + # it's a dict + items = items.items() + for key, value in items: + self.setConfig(key, value) + + def extendMarkdown(self, md: Markdown) -> None: + """ + Add the various processors and patterns to the Markdown Instance. + + This method must be overridden by every extension. + + Arguments: + md: The Markdown instance. + + """ + raise NotImplementedError( + 'Extension "%s.%s" must define an "extendMarkdown"' + 'method.' % (self.__class__.__module__, self.__class__.__name__) + ) diff --git a/lib/python3.12/site-packages/markdown/extensions/__pycache__/__init__.cpython-312.pyc b/lib/python3.12/site-packages/markdown/extensions/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8073e39224f04a2343d8b4cbd3bee827e04bfa3e Binary files /dev/null and b/lib/python3.12/site-packages/markdown/extensions/__pycache__/__init__.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/markdown/extensions/__pycache__/abbr.cpython-312.pyc b/lib/python3.12/site-packages/markdown/extensions/__pycache__/abbr.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ecce2e593d500d891da4c7a0b973912bab9fa7d0 Binary files /dev/null and b/lib/python3.12/site-packages/markdown/extensions/__pycache__/abbr.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/markdown/extensions/__pycache__/admonition.cpython-312.pyc b/lib/python3.12/site-packages/markdown/extensions/__pycache__/admonition.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e1fa68e570769434173a3418b5fbbfec5877eeae Binary files /dev/null and b/lib/python3.12/site-packages/markdown/extensions/__pycache__/admonition.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/markdown/extensions/__pycache__/attr_list.cpython-312.pyc b/lib/python3.12/site-packages/markdown/extensions/__pycache__/attr_list.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..80f28d5feac7d1801ad39160da0c926dafd527e3 Binary files /dev/null and b/lib/python3.12/site-packages/markdown/extensions/__pycache__/attr_list.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/markdown/extensions/__pycache__/codehilite.cpython-312.pyc b/lib/python3.12/site-packages/markdown/extensions/__pycache__/codehilite.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6bd58fd3fa92f4c372d60e8d01821dbb68e85f36 Binary files /dev/null and b/lib/python3.12/site-packages/markdown/extensions/__pycache__/codehilite.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/markdown/extensions/__pycache__/def_list.cpython-312.pyc b/lib/python3.12/site-packages/markdown/extensions/__pycache__/def_list.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..143a8171078cda395d29f492123c97de9388121c Binary files /dev/null and b/lib/python3.12/site-packages/markdown/extensions/__pycache__/def_list.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/markdown/extensions/__pycache__/extra.cpython-312.pyc b/lib/python3.12/site-packages/markdown/extensions/__pycache__/extra.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..704ca17bd549a65414cc05d7d5b872c566c9a7ec Binary files /dev/null and b/lib/python3.12/site-packages/markdown/extensions/__pycache__/extra.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/markdown/extensions/__pycache__/fenced_code.cpython-312.pyc b/lib/python3.12/site-packages/markdown/extensions/__pycache__/fenced_code.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1b308affd38bb6eab7bd52344f2e1e984d47a8cc Binary files /dev/null and b/lib/python3.12/site-packages/markdown/extensions/__pycache__/fenced_code.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/markdown/extensions/__pycache__/footnotes.cpython-312.pyc b/lib/python3.12/site-packages/markdown/extensions/__pycache__/footnotes.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..97892aca6551713c8ef29e8f603cefe8d4f733d6 Binary files /dev/null and b/lib/python3.12/site-packages/markdown/extensions/__pycache__/footnotes.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/markdown/extensions/__pycache__/legacy_attrs.cpython-312.pyc b/lib/python3.12/site-packages/markdown/extensions/__pycache__/legacy_attrs.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7f417efc23550e7934cb21a43104b4d33dcaf322 Binary files /dev/null and b/lib/python3.12/site-packages/markdown/extensions/__pycache__/legacy_attrs.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/markdown/extensions/__pycache__/legacy_em.cpython-312.pyc b/lib/python3.12/site-packages/markdown/extensions/__pycache__/legacy_em.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..cb53328dd1f365c85286df42ddf610fc6a426e81 Binary files /dev/null and b/lib/python3.12/site-packages/markdown/extensions/__pycache__/legacy_em.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/markdown/extensions/__pycache__/md_in_html.cpython-312.pyc b/lib/python3.12/site-packages/markdown/extensions/__pycache__/md_in_html.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..76c646bc7576ab4a2c155f77d4e92ddb2cd9de86 Binary files /dev/null and b/lib/python3.12/site-packages/markdown/extensions/__pycache__/md_in_html.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/markdown/extensions/__pycache__/meta.cpython-312.pyc b/lib/python3.12/site-packages/markdown/extensions/__pycache__/meta.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1ca317fe93f2f68efa11e071714a480a9a4e9f12 Binary files /dev/null and b/lib/python3.12/site-packages/markdown/extensions/__pycache__/meta.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/markdown/extensions/__pycache__/nl2br.cpython-312.pyc b/lib/python3.12/site-packages/markdown/extensions/__pycache__/nl2br.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ee143c6427c3e9335231b17d2ef69e049fdbacbd Binary files /dev/null and b/lib/python3.12/site-packages/markdown/extensions/__pycache__/nl2br.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/markdown/extensions/__pycache__/sane_lists.cpython-312.pyc b/lib/python3.12/site-packages/markdown/extensions/__pycache__/sane_lists.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e5817b72fc2cc51f7812c8568f4de5478d8ccb7a Binary files /dev/null and b/lib/python3.12/site-packages/markdown/extensions/__pycache__/sane_lists.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/markdown/extensions/__pycache__/smarty.cpython-312.pyc b/lib/python3.12/site-packages/markdown/extensions/__pycache__/smarty.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bd2219e37ea95d573ecddeb1abb2b98903261b9a Binary files /dev/null and b/lib/python3.12/site-packages/markdown/extensions/__pycache__/smarty.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/markdown/extensions/__pycache__/tables.cpython-312.pyc b/lib/python3.12/site-packages/markdown/extensions/__pycache__/tables.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a447b5bbe3d82f125e780bd4de13d018e5ee378b Binary files /dev/null and b/lib/python3.12/site-packages/markdown/extensions/__pycache__/tables.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/markdown/extensions/__pycache__/toc.cpython-312.pyc b/lib/python3.12/site-packages/markdown/extensions/__pycache__/toc.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5ae0aad8ffefbbaed2cdd6db2c514c914f5152b4 Binary files /dev/null and b/lib/python3.12/site-packages/markdown/extensions/__pycache__/toc.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/markdown/extensions/__pycache__/wikilinks.cpython-312.pyc b/lib/python3.12/site-packages/markdown/extensions/__pycache__/wikilinks.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1ef3af02800837d9d34b024274abae05f9339735 Binary files /dev/null and b/lib/python3.12/site-packages/markdown/extensions/__pycache__/wikilinks.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/markdown/extensions/abbr.py b/lib/python3.12/site-packages/markdown/extensions/abbr.py new file mode 100644 index 0000000000000000000000000000000000000000..9e10259b88a8196d9e2139993de94367ac2c5654 --- /dev/null +++ b/lib/python3.12/site-packages/markdown/extensions/abbr.py @@ -0,0 +1,190 @@ +# Abbreviation Extension for Python-Markdown +# ========================================== + +# This extension adds abbreviation handling to Python-Markdown. + +# See https://Python-Markdown.github.io/extensions/abbreviations +# for documentation. + +# Original code Copyright 2007-2008 [Waylan Limberg](https://github.com/waylan) +# and [Seemant Kulleen](http://www.kulleen.org/) + +# All changes Copyright 2008-2014 The Python Markdown Project + +# License: [BSD](https://opensource.org/licenses/bsd-license.php) + +""" +This extension adds abbreviation handling to Python-Markdown. + +See the [documentation](https://Python-Markdown.github.io/extensions/abbreviations) +for details. +""" + +from __future__ import annotations + +from . import Extension +from ..blockprocessors import BlockProcessor +from ..inlinepatterns import InlineProcessor +from ..treeprocessors import Treeprocessor +from ..util import AtomicString, deprecated +from typing import TYPE_CHECKING +import re +import xml.etree.ElementTree as etree + +if TYPE_CHECKING: # pragma: no cover + from .. import Markdown + from ..blockparser import BlockParser + + +class AbbrExtension(Extension): + """ Abbreviation Extension for Python-Markdown. """ + + def __init__(self, **kwargs): + """ Initiate Extension and set up configs. """ + self.config = { + 'glossary': [ + {}, + 'A dictionary where the `key` is the abbreviation and the `value` is the definition.' + "Default: `{}`" + ], + } + """ Default configuration options. """ + super().__init__(**kwargs) + self.abbrs = {} + self.glossary = {} + + def reset(self): + """ Clear all previously defined abbreviations. """ + self.abbrs.clear() + if (self.glossary): + self.abbrs.update(self.glossary) + + def reset_glossary(self): + """ Clear all abbreviations from the glossary. """ + self.glossary.clear() + + def load_glossary(self, dictionary: dict[str, str]): + """Adds `dictionary` to our glossary. Any abbreviations that already exist will be overwritten.""" + if dictionary: + self.glossary = {**dictionary, **self.glossary} + + def extendMarkdown(self, md): + """ Insert `AbbrTreeprocessor` and `AbbrBlockprocessor`. """ + if (self.config['glossary'][0]): + self.load_glossary(self.config['glossary'][0]) + self.abbrs.update(self.glossary) + md.registerExtension(self) + md.treeprocessors.register(AbbrTreeprocessor(md, self.abbrs), 'abbr', 7) + md.parser.blockprocessors.register(AbbrBlockprocessor(md.parser, self.abbrs), 'abbr', 16) + + +class AbbrTreeprocessor(Treeprocessor): + """ Replace abbreviation text with `` elements. """ + + def __init__(self, md: Markdown | None = None, abbrs: dict | None = None): + self.abbrs: dict = abbrs if abbrs is not None else {} + self.RE: re.RegexObject | None = None + super().__init__(md) + + def create_element(self, title: str, text: str, tail: str) -> etree.Element: + ''' Create an `abbr` element. ''' + abbr = etree.Element('abbr', {'title': title}) + abbr.text = AtomicString(text) + abbr.tail = tail + return abbr + + def iter_element(self, el: etree.Element, parent: etree.Element | None = None) -> None: + ''' Recursively iterate over elements, run regex on text and wrap matches in `abbr` tags. ''' + for child in reversed(el): + self.iter_element(child, el) + if text := el.text: + if not isinstance(text, AtomicString): + for m in reversed(list(self.RE.finditer(text))): + if self.abbrs[m.group(0)]: + abbr = self.create_element(self.abbrs[m.group(0)], m.group(0), text[m.end():]) + el.insert(0, abbr) + text = text[:m.start()] + el.text = text + if parent is not None and el.tail: + tail = el.tail + index = list(parent).index(el) + 1 + if not isinstance(tail, AtomicString): + for m in reversed(list(self.RE.finditer(tail))): + abbr = self.create_element(self.abbrs[m.group(0)], m.group(0), tail[m.end():]) + parent.insert(index, abbr) + tail = tail[:m.start()] + el.tail = tail + + def run(self, root: etree.Element) -> etree.Element | None: + ''' Step through tree to find known abbreviations. ''' + if not self.abbrs: + # No abbreviations defined. Skip running processor. + return + # Build and compile regex + abbr_list = list(self.abbrs.keys()) + abbr_list.sort(key=len, reverse=True) + self.RE = re.compile(f"\\b(?:{ '|'.join(re.escape(key) for key in abbr_list) })\\b") + # Step through tree and modify on matches + self.iter_element(root) + + +class AbbrBlockprocessor(BlockProcessor): + """ Parse text for abbreviation references. """ + + RE = re.compile(r'^[*]\[(?P[^\\]*?)\][ ]?:[ ]*\n?[ ]*(?P.*)$', re.MULTILINE) + + def __init__(self, parser: BlockParser, abbrs: dict): + self.abbrs: dict = abbrs + super().__init__(parser) + + def test(self, parent: etree.Element, block: str) -> bool: + return True + + def run(self, parent: etree.Element, blocks: list[str]) -> bool: + """ + Find and remove all abbreviation references from the text. + Each reference is added to the abbreviation collection. + + """ + block = blocks.pop(0) + m = self.RE.search(block) + if m: + abbr = m.group('abbr').strip() + title = m.group('title').strip() + if title and abbr: + if title == "''" or title == '""': + self.abbrs.pop(abbr) + else: + self.abbrs[abbr] = title + if block[m.end():].strip(): + # Add any content after match back to blocks as separate block + blocks.insert(0, block[m.end():].lstrip('\n')) + if block[:m.start()].strip(): + # Add any content before match back to blocks as separate block + blocks.insert(0, block[:m.start()].rstrip('\n')) + return True + # No match. Restore block. + blocks.insert(0, block) + return False + + +AbbrPreprocessor = deprecated("This class has been renamed to `AbbrBlockprocessor`.")(AbbrBlockprocessor) + + +@deprecated("This class will be removed in the future; use `AbbrTreeprocessor` instead.") +class AbbrInlineProcessor(InlineProcessor): + """ Abbreviation inline pattern. """ + + def __init__(self, pattern: str, title: str): + super().__init__(pattern) + self.title = title + + def handleMatch(self, m: re.Match[str], data: str) -> tuple[etree.Element, int, int]: + abbr = etree.Element('abbr') + abbr.text = AtomicString(m.group('abbr')) + abbr.set('title', self.title) + return abbr, m.start(0), m.end(0) + + +def makeExtension(**kwargs): # pragma: no cover + return AbbrExtension(**kwargs) diff --git a/lib/python3.12/site-packages/markdown/extensions/admonition.py b/lib/python3.12/site-packages/markdown/extensions/admonition.py new file mode 100644 index 0000000000000000000000000000000000000000..edfeb4964284bfd024f5206a62bfe859ff2c6355 --- /dev/null +++ b/lib/python3.12/site-packages/markdown/extensions/admonition.py @@ -0,0 +1,184 @@ +# Admonition extension for Python-Markdown +# ======================================== + +# Adds rST-style admonitions. Inspired by [rST][] feature with the same name. + +# [rST]: http://docutils.sourceforge.net/docs/ref/rst/directives.html#specific-admonitions + +# See https://Python-Markdown.github.io/extensions/admonition +# for documentation. + +# Original code Copyright [Tiago Serafim](https://www.tiagoserafim.com/). + +# All changes Copyright The Python Markdown Project + +# License: [BSD](https://opensource.org/licenses/bsd-license.php) + + +""" +Adds rST-style admonitions to Python-Markdown. +Inspired by [rST][] feature with the same name. + +[rST]: http://docutils.sourceforge.net/docs/ref/rst/directives.html#specific-admonitions + +See the [documentation](https://Python-Markdown.github.io/extensions/admonition) +for details. +""" + +from __future__ import annotations + +from . import Extension +from ..blockprocessors import BlockProcessor +import xml.etree.ElementTree as etree +import re +from typing import TYPE_CHECKING + +if TYPE_CHECKING: # pragma: no cover + from markdown import blockparser + + +class AdmonitionExtension(Extension): + """ Admonition extension for Python-Markdown. """ + + def extendMarkdown(self, md): + """ Add Admonition to Markdown instance. """ + md.registerExtension(self) + + md.parser.blockprocessors.register(AdmonitionProcessor(md.parser), 'admonition', 105) + + +class AdmonitionProcessor(BlockProcessor): + + CLASSNAME = 'admonition' + CLASSNAME_TITLE = 'admonition-title' + RE = re.compile(r'(?:^|\n)!!! ?([\w\-]+(?: +[\w\-]+)*)(?: +"(.*?)")? *(?:\n|$)') + RE_SPACES = re.compile(' +') + + def __init__(self, parser: blockparser.BlockParser): + """Initialization.""" + + super().__init__(parser) + + self.current_sibling: etree.Element | None = None + self.content_indent = 0 + + def parse_content(self, parent: etree.Element, block: str) -> tuple[etree.Element | None, str, str]: + """Get sibling admonition. + + Retrieve the appropriate sibling element. This can get tricky when + dealing with lists. + + """ + + old_block = block + the_rest = '' + + # We already acquired the block via test + if self.current_sibling is not None: + sibling = self.current_sibling + block, the_rest = self.detab(block, self.content_indent) + self.current_sibling = None + self.content_indent = 0 + return sibling, block, the_rest + + sibling = self.lastChild(parent) + + if sibling is None or sibling.tag != 'div' or sibling.get('class', '').find(self.CLASSNAME) == -1: + sibling = None + else: + # If the last child is a list and the content is sufficiently indented + # to be under it, then the content's sibling is in the list. + last_child = self.lastChild(sibling) + indent = 0 + while last_child is not None: + if ( + sibling is not None and block.startswith(' ' * self.tab_length * 2) and + last_child is not None and last_child.tag in ('ul', 'ol', 'dl') + ): + + # The expectation is that we'll find an `<li>` or `<dt>`. + # We should get its last child as well. + sibling = self.lastChild(last_child) + last_child = self.lastChild(sibling) if sibling is not None else None + + # Context has been lost at this point, so we must adjust the + # text's indentation level so it will be evaluated correctly + # under the list. + block = block[self.tab_length:] + indent += self.tab_length + else: + last_child = None + + if not block.startswith(' ' * self.tab_length): + sibling = None + + if sibling is not None: + indent += self.tab_length + block, the_rest = self.detab(old_block, indent) + self.current_sibling = sibling + self.content_indent = indent + + return sibling, block, the_rest + + def test(self, parent: etree.Element, block: str) -> bool: + + if self.RE.search(block): + return True + else: + return self.parse_content(parent, block)[0] is not None + + def run(self, parent: etree.Element, blocks: list[str]) -> None: + block = blocks.pop(0) + m = self.RE.search(block) + + if m: + if m.start() > 0: + self.parser.parseBlocks(parent, [block[:m.start()]]) + block = block[m.end():] # removes the first line + block, theRest = self.detab(block) + else: + sibling, block, theRest = self.parse_content(parent, block) + + if m: + klass, title = self.get_class_and_title(m) + div = etree.SubElement(parent, 'div') + div.set('class', '{} {}'.format(self.CLASSNAME, klass)) + if title: + p = etree.SubElement(div, 'p') + p.text = title + p.set('class', self.CLASSNAME_TITLE) + else: + # Sibling is a list item, but we need to wrap it's content should be wrapped in <p> + if sibling.tag in ('li', 'dd') and sibling.text: + text = sibling.text + sibling.text = '' + p = etree.SubElement(sibling, 'p') + p.text = text + + div = sibling + + self.parser.parseChunk(div, block) + + if theRest: + # This block contained unindented line(s) after the first indented + # line. Insert these lines as the first block of the master blocks + # list for future processing. + blocks.insert(0, theRest) + + def get_class_and_title(self, match: re.Match[str]) -> tuple[str, str | None]: + klass, title = match.group(1).lower(), match.group(2) + klass = self.RE_SPACES.sub(' ', klass) + if title is None: + # no title was provided, use the capitalized class name as title + # e.g.: `!!! note` will render + # `<p class="admonition-title">Note</p>` + title = klass.split(' ', 1)[0].capitalize() + elif title == '': + # an explicit blank title should not be rendered + # e.g.: `!!! warning ""` will *not* render `p` with a title + title = None + return klass, title + + +def makeExtension(**kwargs): # pragma: no cover + return AdmonitionExtension(**kwargs) diff --git a/lib/python3.12/site-packages/markdown/extensions/attr_list.py b/lib/python3.12/site-packages/markdown/extensions/attr_list.py new file mode 100644 index 0000000000000000000000000000000000000000..52ac38a6bc2cf1869ce5a13e730056f144b12e7b --- /dev/null +++ b/lib/python3.12/site-packages/markdown/extensions/attr_list.py @@ -0,0 +1,204 @@ +# Attribute List Extension for Python-Markdown +# ============================================ + +# Adds attribute list syntax. Inspired by +# [Maruku](http://maruku.rubyforge.org/proposal.html#attribute_lists)'s +# feature of the same name. + +# See https://Python-Markdown.github.io/extensions/attr_list +# for documentation. + +# Original code Copyright 2011 [Waylan Limberg](https://github.com/waylan). + +# All changes Copyright 2011-2014 The Python Markdown Project + +# License: [BSD](https://opensource.org/licenses/bsd-license.php) + +""" +Adds attribute list syntax to Python-Markdown. +Inspired by +[Maruku](http://maruku.rubyforge.org/proposal.html#attribute_lists)'s +feature of the same name. + +See the [documentation](https://Python-Markdown.github.io/extensions/attr_list) +for details. +""" + +from __future__ import annotations +from typing import TYPE_CHECKING + +from . import Extension +from ..treeprocessors import Treeprocessor +import re + +if TYPE_CHECKING: # pragma: no cover + from xml.etree.ElementTree import Element + + +def _handle_double_quote(s, t): + k, v = t.split('=', 1) + return k, v.strip('"') + + +def _handle_single_quote(s, t): + k, v = t.split('=', 1) + return k, v.strip("'") + + +def _handle_key_value(s, t): + return t.split('=', 1) + + +def _handle_word(s, t): + if t.startswith('.'): + return '.', t[1:] + if t.startswith('#'): + return 'id', t[1:] + return t, t + + +_scanner = re.Scanner([ + (r'[^ =}]+=".*?"', _handle_double_quote), + (r"[^ =}]+='.*?'", _handle_single_quote), + (r'[^ =}]+=[^ =}]+', _handle_key_value), + (r'[^ =}]+', _handle_word), + (r' ', None) +]) + + +def get_attrs_and_remainder(attrs_string: str) -> tuple[list[tuple[str, str]], str]: + """ Parse attribute list and return a list of attribute tuples. + + Additionally, return any text that remained after a curly brace. In typical cases, its presence + should mean that the input does not match the intended attribute list syntax. + """ + attrs, remainder = _scanner.scan(attrs_string) + # To keep historic behavior, discard all unparsable text prior to '}'. + index = remainder.find('}') + remainder = remainder[index:] if index != -1 else '' + return attrs, remainder + + +def get_attrs(str: str) -> list[tuple[str, str]]: # pragma: no cover + """ Soft-deprecated. Prefer `get_attrs_and_remainder`. """ + return get_attrs_and_remainder(str)[0] + + +def isheader(elem: Element) -> bool: + return elem.tag in ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] + + +class AttrListTreeprocessor(Treeprocessor): + + BASE_RE = r'\{\:?[ ]*([^\}\n ][^\n]*)[ ]*\}' + HEADER_RE = re.compile(r'[ ]+{}[ ]*$'.format(BASE_RE)) + BLOCK_RE = re.compile(r'\n[ ]*{}[ ]*$'.format(BASE_RE)) + INLINE_RE = re.compile(r'^{}'.format(BASE_RE)) + NAME_RE = re.compile(r'[^A-Z_a-z\u00c0-\u00d6\u00d8-\u00f6\u00f8-\u02ff' + r'\u0370-\u037d\u037f-\u1fff\u200c-\u200d' + r'\u2070-\u218f\u2c00-\u2fef\u3001-\ud7ff' + r'\uf900-\ufdcf\ufdf0-\ufffd' + r'\:\-\.0-9\u00b7\u0300-\u036f\u203f-\u2040]+') + + def run(self, doc: Element) -> None: + for elem in doc.iter(): + if self.md.is_block_level(elem.tag): + # Block level: check for `attrs` on last line of text + RE = self.BLOCK_RE + if isheader(elem) or elem.tag in ['dt', 'td', 'th']: + # header, def-term, or table cell: check for attributes at end of element + RE = self.HEADER_RE + if len(elem) and elem.tag == 'li': + # special case list items. children may include a `ul` or `ol`. + pos = None + # find the `ul` or `ol` position + for i, child in enumerate(elem): + if child.tag in ['ul', 'ol']: + pos = i + break + if pos is None and elem[-1].tail: + # use tail of last child. no `ul` or `ol`. + m = RE.search(elem[-1].tail) + if m: + if not self.assign_attrs(elem, m.group(1), strict=True): + elem[-1].tail = elem[-1].tail[:m.start()] + elif pos is not None and pos > 0 and elem[pos-1].tail: + # use tail of last child before `ul` or `ol` + m = RE.search(elem[pos-1].tail) + if m: + if not self.assign_attrs(elem, m.group(1), strict=True): + elem[pos-1].tail = elem[pos-1].tail[:m.start()] + elif elem.text: + # use text. `ul` is first child. + m = RE.search(elem.text) + if m: + if not self.assign_attrs(elem, m.group(1), strict=True): + elem.text = elem.text[:m.start()] + elif len(elem) and elem[-1].tail: + # has children. Get from tail of last child + m = RE.search(elem[-1].tail) + if m: + if not self.assign_attrs(elem, m.group(1), strict=True): + elem[-1].tail = elem[-1].tail[:m.start()] + if isheader(elem): + # clean up trailing #s + elem[-1].tail = elem[-1].tail.rstrip('#').rstrip() + elif elem.text: + # no children. Get from text. + m = RE.search(elem.text) + if m: + if not self.assign_attrs(elem, m.group(1), strict=True): + elem.text = elem.text[:m.start()] + if isheader(elem): + # clean up trailing #s + elem.text = elem.text.rstrip('#').rstrip() + else: + # inline: check for `attrs` at start of tail + if elem.tail: + m = self.INLINE_RE.match(elem.tail) + if m: + remainder = self.assign_attrs(elem, m.group(1)) + elem.tail = elem.tail[m.end():] + remainder + + def assign_attrs(self, elem: Element, attrs_string: str, *, strict: bool = False) -> str: + """ Assign `attrs` to element. + + If the `attrs_string` has an extra closing curly brace, the remaining text is returned. + + The `strict` argument controls whether to still assign `attrs` if there is a remaining `}`. + """ + attrs, remainder = get_attrs_and_remainder(attrs_string) + if strict and remainder: + return remainder + + for k, v in attrs: + if k == '.': + # add to class + cls = elem.get('class') + if cls: + elem.set('class', '{} {}'.format(cls, v)) + else: + elem.set('class', v) + else: + # assign attribute `k` with `v` + elem.set(self.sanitize_name(k), v) + # The text that we initially over-matched will be put back. + return remainder + + def sanitize_name(self, name: str) -> str: + """ + Sanitize name as 'an XML Name, minus the `:`.' + See <https://www.w3.org/TR/REC-xml-names/#NT-NCName>. + """ + return self.NAME_RE.sub('_', name) + + +class AttrListExtension(Extension): + """ Attribute List extension for Python-Markdown """ + def extendMarkdown(self, md): + md.treeprocessors.register(AttrListTreeprocessor(md), 'attr_list', 8) + md.registerExtension(self) + + +def makeExtension(**kwargs): # pragma: no cover + return AttrListExtension(**kwargs) diff --git a/lib/python3.12/site-packages/markdown/extensions/codehilite.py b/lib/python3.12/site-packages/markdown/extensions/codehilite.py new file mode 100644 index 0000000000000000000000000000000000000000..be45a3f1847dadcbb7e107a6e6831256a6f5596d --- /dev/null +++ b/lib/python3.12/site-packages/markdown/extensions/codehilite.py @@ -0,0 +1,347 @@ +# CodeHilite Extension for Python-Markdown +# ======================================== + +# Adds code/syntax highlighting to standard Python-Markdown code blocks. + +# See https://Python-Markdown.github.io/extensions/code_hilite +# for documentation. + +# Original code Copyright 2006-2008 [Waylan Limberg](https://github.com/waylan). + +# All changes Copyright 2008-2014 The Python Markdown Project + +# License: [BSD](https://opensource.org/licenses/bsd-license.php) + +""" +Adds code/syntax highlighting to standard Python-Markdown code blocks. + +See the [documentation](https://Python-Markdown.github.io/extensions/code_hilite) +for details. +""" + +from __future__ import annotations + +from . import Extension +from ..treeprocessors import Treeprocessor +from ..util import parseBoolValue +from typing import TYPE_CHECKING, Callable, Any + +if TYPE_CHECKING: # pragma: no cover + import xml.etree.ElementTree as etree + +try: # pragma: no cover + from pygments import highlight + from pygments.lexers import get_lexer_by_name, guess_lexer + from pygments.formatters import get_formatter_by_name + from pygments.util import ClassNotFound + pygments = True +except ImportError: # pragma: no cover + pygments = False + + +def parse_hl_lines(expr: str) -> list[int]: + """Support our syntax for emphasizing certain lines of code. + + `expr` should be like '1 2' to emphasize lines 1 and 2 of a code block. + Returns a list of integers, the line numbers to emphasize. + """ + if not expr: + return [] + + try: + return list(map(int, expr.split())) + except ValueError: # pragma: no cover + return [] + + +# ------------------ The Main CodeHilite Class ---------------------- +class CodeHilite: + """ + Determine language of source code, and pass it on to the Pygments highlighter. + + Usage: + + ```python + code = CodeHilite(src=some_code, lang='python') + html = code.hilite() + ``` + + Arguments: + src: Source string or any object with a `.readline` attribute. + + Keyword arguments: + lang (str): String name of Pygments lexer to use for highlighting. Default: `None`. + guess_lang (bool): Auto-detect which lexer to use. + Ignored if `lang` is set to a valid value. Default: `True`. + use_pygments (bool): Pass code to Pygments for code highlighting. If `False`, the code is + instead wrapped for highlighting by a JavaScript library. Default: `True`. + pygments_formatter (str): The name of a Pygments formatter or a formatter class used for + highlighting the code blocks. Default: `html`. + linenums (bool): An alias to Pygments `linenos` formatter option. Default: `None`. + css_class (str): An alias to Pygments `cssclass` formatter option. Default: 'codehilite'. + lang_prefix (str): Prefix prepended to the language. Default: "language-". + + Other Options: + + Any other options are accepted and passed on to the lexer and formatter. Therefore, + valid options include any options which are accepted by the `html` formatter or + whichever lexer the code's language uses. Note that most lexers do not have any + options. However, a few have very useful options, such as PHP's `startinline` option. + Any invalid options are ignored without error. + + * **Formatter options**: <https://pygments.org/docs/formatters/#HtmlFormatter> + * **Lexer Options**: <https://pygments.org/docs/lexers/> + + Additionally, when Pygments is enabled, the code's language is passed to the + formatter as an extra option `lang_str`, whose value being `{lang_prefix}{lang}`. + This option has no effect to the Pygments' builtin formatters. + + Advanced Usage: + + ```python + code = CodeHilite( + src = some_code, + lang = 'php', + startinline = True, # Lexer option. Snippet does not start with `<?php`. + linenostart = 42, # Formatter option. Snippet starts on line 42. + hl_lines = [45, 49, 50], # Formatter option. Highlight lines 45, 49, and 50. + linenos = 'inline' # Formatter option. Avoid alignment problems. + ) + html = code.hilite() + ``` + + """ + + def __init__(self, src: str, **options): + self.src = src + self.lang: str | None = options.pop('lang', None) + self.guess_lang: bool = options.pop('guess_lang', True) + self.use_pygments: bool = options.pop('use_pygments', True) + self.lang_prefix: str = options.pop('lang_prefix', 'language-') + self.pygments_formatter: str | Callable = options.pop('pygments_formatter', 'html') + + if 'linenos' not in options: + options['linenos'] = options.pop('linenums', None) + if 'cssclass' not in options: + options['cssclass'] = options.pop('css_class', 'codehilite') + if 'wrapcode' not in options: + # Override Pygments default + options['wrapcode'] = True + # Disallow use of `full` option + options['full'] = False + + self.options = options + + def hilite(self, shebang: bool = True) -> str: + """ + Pass code to the [Pygments](https://pygments.org/) highlighter with + optional line numbers. The output should then be styled with CSS to + your liking. No styles are applied by default - only styling hooks + (i.e.: `<span class="k">`). + + returns : A string of html. + + """ + + self.src = self.src.strip('\n') + + if self.lang is None and shebang: + self._parseHeader() + + if pygments and self.use_pygments: + try: + lexer = get_lexer_by_name(self.lang, **self.options) + except ValueError: + try: + if self.guess_lang: + lexer = guess_lexer(self.src, **self.options) + else: + lexer = get_lexer_by_name('text', **self.options) + except ValueError: # pragma: no cover + lexer = get_lexer_by_name('text', **self.options) + if not self.lang: + # Use the guessed lexer's language instead + self.lang = lexer.aliases[0] + lang_str = f'{self.lang_prefix}{self.lang}' + if isinstance(self.pygments_formatter, str): + try: + formatter = get_formatter_by_name(self.pygments_formatter, **self.options) + except ClassNotFound: + formatter = get_formatter_by_name('html', **self.options) + else: + formatter = self.pygments_formatter(lang_str=lang_str, **self.options) + return highlight(self.src, lexer, formatter) + else: + # just escape and build markup usable by JavaScript highlighting libraries + txt = self.src.replace('&', '&') + txt = txt.replace('<', '<') + txt = txt.replace('>', '>') + txt = txt.replace('"', '"') + classes = [] + if self.lang: + classes.append('{}{}'.format(self.lang_prefix, self.lang)) + if self.options['linenos']: + classes.append('linenums') + class_str = '' + if classes: + class_str = ' class="{}"'.format(' '.join(classes)) + return '<pre class="{}"><code{}>{}\n</code></pre>\n'.format( + self.options['cssclass'], + class_str, + txt + ) + + def _parseHeader(self) -> None: + """ + Determines language of a code block from shebang line and whether the + said line should be removed or left in place. If the shebang line + contains a path (even a single /) then it is assumed to be a real + shebang line and left alone. However, if no path is given + (e.i.: `#!python` or `:::python`) then it is assumed to be a mock shebang + for language identification of a code fragment and removed from the + code block prior to processing for code highlighting. When a mock + shebang (e.i: `#!python`) is found, line numbering is turned on. When + colons are found in place of a shebang (e.i.: `:::python`), line + numbering is left in the current state - off by default. + + Also parses optional list of highlight lines, like: + + :::python hl_lines="1 3" + """ + + import re + + # split text into lines + lines = self.src.split("\n") + # pull first line to examine + fl = lines.pop(0) + + c = re.compile(r''' + (?:(?:^::+)|(?P<shebang>^[#]!)) # Shebang or 2 or more colons + (?P<path>(?:/\w+)*[/ ])? # Zero or 1 path + (?P<lang>[\w#.+-]*) # The language + \s* # Arbitrary whitespace + # Optional highlight lines, single- or double-quote-delimited + (hl_lines=(?P<quot>"|')(?P<hl_lines>.*?)(?P=quot))? + ''', re.VERBOSE) + # search first line for shebang + m = c.search(fl) + if m: + # we have a match + try: + self.lang = m.group('lang').lower() + except IndexError: # pragma: no cover + self.lang = None + if m.group('path'): + # path exists - restore first line + lines.insert(0, fl) + if self.options['linenos'] is None and m.group('shebang'): + # Overridable and Shebang exists - use line numbers + self.options['linenos'] = True + + self.options['hl_lines'] = parse_hl_lines(m.group('hl_lines')) + else: + # No match + lines.insert(0, fl) + + self.src = "\n".join(lines).strip("\n") + + +# ------------------ The Markdown Extension ------------------------------- + + +class HiliteTreeprocessor(Treeprocessor): + """ Highlight source code in code blocks. """ + + config: dict[str, Any] + + def code_unescape(self, text: str) -> str: + """Unescape code.""" + text = text.replace("<", "<") + text = text.replace(">", ">") + # Escaped '&' should be replaced at the end to avoid + # conflicting with < and >. + text = text.replace("&", "&") + return text + + def run(self, root: etree.Element) -> None: + """ Find code blocks and store in `htmlStash`. """ + blocks = root.iter('pre') + for block in blocks: + if len(block) == 1 and block[0].tag == 'code': + local_config = self.config.copy() + text = block[0].text + if text is None: + continue + code = CodeHilite( + self.code_unescape(text), + tab_length=self.md.tab_length, + style=local_config.pop('pygments_style', 'default'), + **local_config + ) + placeholder = self.md.htmlStash.store(code.hilite()) + # Clear code block in `etree` instance + block.clear() + # Change to `p` element which will later + # be removed when inserting raw html + block.tag = 'p' + block.text = placeholder + + +class CodeHiliteExtension(Extension): + """ Add source code highlighting to markdown code blocks. """ + + def __init__(self, **kwargs): + # define default configs + self.config = { + 'linenums': [ + None, "Use lines numbers. True|table|inline=yes, False=no, None=auto. Default: `None`." + ], + 'guess_lang': [ + True, "Automatic language detection - Default: `True`." + ], + 'css_class': [ + "codehilite", "Set class name for wrapper <div> - Default: `codehilite`." + ], + 'pygments_style': [ + 'default', 'Pygments HTML Formatter Style (Colorscheme). Default: `default`.' + ], + 'noclasses': [ + False, 'Use inline styles instead of CSS classes - Default `False`.' + ], + 'use_pygments': [ + True, 'Highlight code blocks with pygments. Disable if using a JavaScript library. Default: `True`.' + ], + 'lang_prefix': [ + 'language-', 'Prefix prepended to the language when `use_pygments` is false. Default: `language-`.' + ], + 'pygments_formatter': [ + 'html', 'Use a specific formatter for Pygments highlighting. Default: `html`.' + ], + } + """ Default configuration options. """ + + for key, value in kwargs.items(): + if key in self.config: + self.setConfig(key, value) + else: + # manually set unknown keywords. + if isinstance(value, str): + try: + # Attempt to parse `str` as a boolean value + value = parseBoolValue(value, preserve_none=True) + except ValueError: + pass # Assume it's not a boolean value. Use as-is. + self.config[key] = [value, ''] + + def extendMarkdown(self, md): + """ Add `HilitePostprocessor` to Markdown instance. """ + hiliter = HiliteTreeprocessor(md) + hiliter.config = self.getConfigs() + md.treeprocessors.register(hiliter, 'hilite', 30) + + md.registerExtension(self) + + +def makeExtension(**kwargs): # pragma: no cover + return CodeHiliteExtension(**kwargs) diff --git a/lib/python3.12/site-packages/markdown/extensions/def_list.py b/lib/python3.12/site-packages/markdown/extensions/def_list.py new file mode 100644 index 0000000000000000000000000000000000000000..f09abe379d749b7985ed3e24aafcc9c1be5b10cf --- /dev/null +++ b/lib/python3.12/site-packages/markdown/extensions/def_list.py @@ -0,0 +1,119 @@ +# Definition List Extension for Python-Markdown +# ============================================= + +# Adds parsing of Definition Lists to Python-Markdown. + +# See https://Python-Markdown.github.io/extensions/definition_lists +# for documentation. + +# Original code Copyright 2008 [Waylan Limberg](https://github.com/waylan) + +# All changes Copyright 2008-2014 The Python Markdown Project + +# License: [BSD](https://opensource.org/licenses/bsd-license.php) + +""" +Adds parsing of Definition Lists to Python-Markdown. + +See the [documentation](https://Python-Markdown.github.io/extensions/definition_lists) +for details. +""" + +from __future__ import annotations + +from . import Extension +from ..blockprocessors import BlockProcessor, ListIndentProcessor +import xml.etree.ElementTree as etree +import re + + +class DefListProcessor(BlockProcessor): + """ Process Definition Lists. """ + + RE = re.compile(r'(^|\n)[ ]{0,3}:[ ]{1,3}(.*?)(\n|$)') + NO_INDENT_RE = re.compile(r'^[ ]{0,3}[^ :]') + + def test(self, parent: etree.Element, block: str) -> bool: + return bool(self.RE.search(block)) + + def run(self, parent: etree.Element, blocks: list[str]) -> bool | None: + + raw_block = blocks.pop(0) + m = self.RE.search(raw_block) + terms = [term.strip() for term in + raw_block[:m.start()].split('\n') if term.strip()] + block = raw_block[m.end():] + no_indent = self.NO_INDENT_RE.match(block) + if no_indent: + d, theRest = (block, None) + else: + d, theRest = self.detab(block) + if d: + d = '{}\n{}'.format(m.group(2), d) + else: + d = m.group(2) + sibling = self.lastChild(parent) + if not terms and sibling is None: + # This is not a definition item. Most likely a paragraph that + # starts with a colon at the beginning of a document or list. + blocks.insert(0, raw_block) + return False + if not terms and sibling.tag == 'p': + # The previous paragraph contains the terms + state = 'looselist' + terms = sibling.text.split('\n') + parent.remove(sibling) + # Acquire new sibling + sibling = self.lastChild(parent) + else: + state = 'list' + + if sibling is not None and sibling.tag == 'dl': + # This is another item on an existing list + dl = sibling + if not terms and len(dl) and dl[-1].tag == 'dd' and len(dl[-1]): + state = 'looselist' + else: + # This is a new list + dl = etree.SubElement(parent, 'dl') + # Add terms + for term in terms: + dt = etree.SubElement(dl, 'dt') + dt.text = term + # Add definition + self.parser.state.set(state) + dd = etree.SubElement(dl, 'dd') + self.parser.parseBlocks(dd, [d]) + self.parser.state.reset() + + if theRest: + blocks.insert(0, theRest) + + +class DefListIndentProcessor(ListIndentProcessor): + """ Process indented children of definition list items. """ + + # Definition lists need to be aware of all list types + ITEM_TYPES = ['dd', 'li'] + """ Include `dd` in list item types. """ + LIST_TYPES = ['dl', 'ol', 'ul'] + """ Include `dl` is list types. """ + + def create_item(self, parent: etree.Element, block: str) -> None: + """ Create a new `dd` or `li` (depending on parent) and parse the block with it as the parent. """ + + dd = etree.SubElement(parent, 'dd') + self.parser.parseBlocks(dd, [block]) + + +class DefListExtension(Extension): + """ Add definition lists to Markdown. """ + + def extendMarkdown(self, md): + """ Add an instance of `DefListProcessor` to `BlockParser`. """ + md.parser.blockprocessors.register(DefListIndentProcessor(md.parser), 'defindent', 85) + md.parser.blockprocessors.register(DefListProcessor(md.parser), 'deflist', 25) + + +def makeExtension(**kwargs): # pragma: no cover + return DefListExtension(**kwargs) diff --git a/lib/python3.12/site-packages/markdown/extensions/extra.py b/lib/python3.12/site-packages/markdown/extensions/extra.py new file mode 100644 index 0000000000000000000000000000000000000000..74ebc192ca80da9813bf8bf040008c707a343766 --- /dev/null +++ b/lib/python3.12/site-packages/markdown/extensions/extra.py @@ -0,0 +1,66 @@ +# Python-Markdown Extra Extension +# =============================== + +# A compilation of various Python-Markdown extensions that imitates +# [PHP Markdown Extra](http://michelf.com/projects/php-markdown/extra/). + +# See https://Python-Markdown.github.io/extensions/extra +# for documentation. + +# Copyright The Python Markdown Project + +# License: [BSD](https://opensource.org/licenses/bsd-license.php) + +""" +A compilation of various Python-Markdown extensions that imitates +[PHP Markdown Extra](http://michelf.com/projects/php-markdown/extra/). + +Note that each of the individual extensions still need to be available +on your `PYTHONPATH`. This extension simply wraps them all up as a +convenience so that only one extension needs to be listed when +initiating Markdown. See the documentation for each individual +extension for specifics about that extension. + +There may be additional extensions that are distributed with +Python-Markdown that are not included here in Extra. Those extensions +are not part of PHP Markdown Extra, and therefore, not part of +Python-Markdown Extra. If you really would like Extra to include +additional extensions, we suggest creating your own clone of Extra +under a different name. You could also edit the `extensions` global +variable defined below, but be aware that such changes may be lost +when you upgrade to any future version of Python-Markdown. + +See the [documentation](https://Python-Markdown.github.io/extensions/extra) +for details. +""" + +from __future__ import annotations + +from . import Extension + +extensions = [ + 'fenced_code', + 'footnotes', + 'attr_list', + 'def_list', + 'tables', + 'abbr', + 'md_in_html' +] +""" The list of included extensions. """ + + +class ExtraExtension(Extension): + """ Add various extensions to Markdown class.""" + + def __init__(self, **kwargs): + """ `config` is a dumb holder which gets passed to the actual extension later. """ + self.config = kwargs + + def extendMarkdown(self, md): + """ Register extension instances. """ + md.registerExtensions(extensions, self.config) + + +def makeExtension(**kwargs): # pragma: no cover + return ExtraExtension(**kwargs) diff --git a/lib/python3.12/site-packages/markdown/extensions/fenced_code.py b/lib/python3.12/site-packages/markdown/extensions/fenced_code.py new file mode 100644 index 0000000000000000000000000000000000000000..709e9238cb1da866eaf728a5f461783f749f7d36 --- /dev/null +++ b/lib/python3.12/site-packages/markdown/extensions/fenced_code.py @@ -0,0 +1,193 @@ +# Fenced Code Extension for Python Markdown +# ========================================= + +# This extension adds Fenced Code Blocks to Python-Markdown. + +# See https://Python-Markdown.github.io/extensions/fenced_code_blocks +# for documentation. + +# Original code Copyright 2007-2008 [Waylan Limberg](https://github.com/waylan). + +# All changes Copyright 2008-2014 The Python Markdown Project + +# License: [BSD](https://opensource.org/licenses/bsd-license.php) + +""" +This extension adds Fenced Code Blocks to Python-Markdown. + +See the [documentation](https://Python-Markdown.github.io/extensions/fenced_code_blocks) +for details. +""" + +from __future__ import annotations + +from textwrap import dedent +from . import Extension +from ..preprocessors import Preprocessor +from .codehilite import CodeHilite, CodeHiliteExtension, parse_hl_lines +from .attr_list import get_attrs_and_remainder, AttrListExtension +from ..util import parseBoolValue +from ..serializers import _escape_attrib_html +import re +from typing import TYPE_CHECKING, Any, Iterable + +if TYPE_CHECKING: # pragma: no cover + from markdown import Markdown + + +class FencedCodeExtension(Extension): + def __init__(self, **kwargs): + self.config = { + 'lang_prefix': ['language-', 'Prefix prepended to the language. Default: "language-"'] + } + """ Default configuration options. """ + super().__init__(**kwargs) + + def extendMarkdown(self, md): + """ Add `FencedBlockPreprocessor` to the Markdown instance. """ + md.registerExtension(self) + + md.preprocessors.register(FencedBlockPreprocessor(md, self.getConfigs()), 'fenced_code_block', 25) + + +class FencedBlockPreprocessor(Preprocessor): + """ Find and extract fenced code blocks. """ + + FENCED_BLOCK_RE = re.compile( + dedent(r''' + (?P<fence>^(?:~{3,}|`{3,}))[ ]* # opening fence + ((\{(?P<attrs>[^\n]*)\})| # (optional {attrs} or + (\.?(?P<lang>[\w#.+-]*)[ ]*)? # optional (.)lang + (hl_lines=(?P<quot>"|')(?P<hl_lines>.*?)(?P=quot)[ ]*)?) # optional hl_lines) + \n # newline (end of opening fence) + (?P<code>.*?)(?<=\n) # the code block + (?P=fence)[ ]*$ # closing fence + '''), + re.MULTILINE | re.DOTALL | re.VERBOSE + ) + + def __init__(self, md: Markdown, config: dict[str, Any]): + super().__init__(md) + self.config = config + self.checked_for_deps = False + self.codehilite_conf: dict[str, Any] = {} + self.use_attr_list = False + # List of options to convert to boolean values + self.bool_options = [ + 'linenums', + 'guess_lang', + 'noclasses', + 'use_pygments' + ] + + def run(self, lines: list[str]) -> list[str]: + """ Match and store Fenced Code Blocks in the `HtmlStash`. """ + + # Check for dependent extensions + if not self.checked_for_deps: + for ext in self.md.registeredExtensions: + if isinstance(ext, CodeHiliteExtension): + self.codehilite_conf = ext.getConfigs() + if isinstance(ext, AttrListExtension): + self.use_attr_list = True + + self.checked_for_deps = True + + text = "\n".join(lines) + index = 0 + while 1: + m = self.FENCED_BLOCK_RE.search(text, index) + if m: + lang, id, classes, config = None, '', [], {} + if m.group('attrs'): + attrs, remainder = get_attrs_and_remainder(m.group('attrs')) + if remainder: # Does not have correctly matching curly braces, so the syntax is invalid. + index = m.end('attrs') # Explicitly skip over this, to prevent an infinite loop. + continue + id, classes, config = self.handle_attrs(attrs) + if len(classes): + lang = classes.pop(0) + else: + if m.group('lang'): + lang = m.group('lang') + if m.group('hl_lines'): + # Support `hl_lines` outside of `attrs` for backward-compatibility + config['hl_lines'] = parse_hl_lines(m.group('hl_lines')) + + # If `config` is not empty, then the `codehighlite` extension + # is enabled, so we call it to highlight the code + if self.codehilite_conf and self.codehilite_conf['use_pygments'] and config.get('use_pygments', True): + local_config = self.codehilite_conf.copy() + local_config.update(config) + # Combine classes with `cssclass`. Ensure `cssclass` is at end + # as Pygments appends a suffix under certain circumstances. + # Ignore ID as Pygments does not offer an option to set it. + if classes: + local_config['css_class'] = '{} {}'.format( + ' '.join(classes), + local_config['css_class'] + ) + highliter = CodeHilite( + m.group('code'), + lang=lang, + style=local_config.pop('pygments_style', 'default'), + **local_config + ) + + code = highliter.hilite(shebang=False) + else: + id_attr = lang_attr = class_attr = kv_pairs = '' + if lang: + prefix = self.config.get('lang_prefix', 'language-') + lang_attr = f' class="{prefix}{_escape_attrib_html(lang)}"' + if classes: + class_attr = f' class="{_escape_attrib_html(" ".join(classes))}"' + if id: + id_attr = f' id="{_escape_attrib_html(id)}"' + if self.use_attr_list and config and not config.get('use_pygments', False): + # Only assign key/value pairs to code element if `attr_list` extension is enabled, key/value + # pairs were defined on the code block, and the `use_pygments` key was not set to `True`. The + # `use_pygments` key could be either set to `False` or not defined. It is omitted from output. + kv_pairs = ''.join( + f' {k}="{_escape_attrib_html(v)}"' for k, v in config.items() if k != 'use_pygments' + ) + code = self._escape(m.group('code')) + code = f'<pre{id_attr}{class_attr}><code{lang_attr}{kv_pairs}>{code}</code></pre>' + + placeholder = self.md.htmlStash.store(code) + text = f'{text[:m.start()]}\n{placeholder}\n{text[m.end():]}' + # Continue from after the replaced text in the next iteration. + index = m.start() + 1 + len(placeholder) + else: + break + return text.split("\n") + + def handle_attrs(self, attrs: Iterable[tuple[str, str]]) -> tuple[str, list[str], dict[str, Any]]: + """ Return tuple: `(id, [list, of, classes], {configs})` """ + id = '' + classes = [] + configs = {} + for k, v in attrs: + if k == 'id': + id = v + elif k == '.': + classes.append(v) + elif k == 'hl_lines': + configs[k] = parse_hl_lines(v) + elif k in self.bool_options: + configs[k] = parseBoolValue(v, fail_on_errors=False, preserve_none=True) + else: + configs[k] = v + return id, classes, configs + + def _escape(self, txt: str) -> str: + """ basic html escaping """ + txt = txt.replace('&', '&') + txt = txt.replace('<', '<') + txt = txt.replace('>', '>') + txt = txt.replace('"', '"') + return txt + + +def makeExtension(**kwargs): # pragma: no cover + return FencedCodeExtension(**kwargs) diff --git a/lib/python3.12/site-packages/markdown/extensions/footnotes.py b/lib/python3.12/site-packages/markdown/extensions/footnotes.py new file mode 100644 index 0000000000000000000000000000000000000000..8787e4dc4883c9d6f17a42050ee2f4b488ff7bea --- /dev/null +++ b/lib/python3.12/site-packages/markdown/extensions/footnotes.py @@ -0,0 +1,482 @@ +# Footnotes Extension for Python-Markdown +# ======================================= + +# Adds footnote handling to Python-Markdown. + +# See https://Python-Markdown.github.io/extensions/footnotes +# for documentation. + +# Copyright The Python Markdown Project + +# License: [BSD](https://opensource.org/licenses/bsd-license.php) + +""" +Adds footnote handling to Python-Markdown. + +See the [documentation](https://Python-Markdown.github.io/extensions/footnotes) +for details. +""" + +from __future__ import annotations + +from . import Extension +from ..blockprocessors import BlockProcessor +from ..inlinepatterns import InlineProcessor +from ..treeprocessors import Treeprocessor +from ..postprocessors import Postprocessor +from .. import util +from collections import OrderedDict +import re +import copy +import xml.etree.ElementTree as etree + +FN_BACKLINK_TEXT = util.STX + "zz1337820767766393qq" + util.ETX +NBSP_PLACEHOLDER = util.STX + "qq3936677670287331zz" + util.ETX +RE_REF_ID = re.compile(r'(fnref)(\d+)') +RE_REFERENCE = re.compile(r'(?<!!)\[\^([^\]]*)\](?!\s*:)') + + +class FootnoteExtension(Extension): + """ Footnote Extension. """ + + def __init__(self, **kwargs): + """ Setup configs. """ + + self.config = { + 'PLACE_MARKER': [ + '///Footnotes Go Here///', 'The text string that marks where the footnotes go' + ], + 'UNIQUE_IDS': [ + False, 'Avoid name collisions across multiple calls to `reset()`.' + ], + 'BACKLINK_TEXT': [ + '↩', "The text string that links from the footnote to the reader's place." + ], + 'SUPERSCRIPT_TEXT': [ + '{}', "The text string that links from the reader's place to the footnote." + ], + 'BACKLINK_TITLE': [ + 'Jump back to footnote %d in the text', + 'The text string used for the title HTML attribute of the backlink. ' + '%d will be replaced by the footnote number.' + ], + 'SEPARATOR': [ + ':', 'Footnote separator.' + ], + 'USE_DEFINITION_ORDER': [ + True, + 'Order footnote labels by definition order (True) or by document order (False). ' + 'Default: True.' + ] + } + """ Default configuration options. """ + super().__init__(**kwargs) + + # In multiple invocations, emit links that don't get tangled. + self.unique_prefix = 0 + self.found_refs: dict[str, int] = {} + self.used_refs: set[str] = set() + + # Backward compatibility with old '%d' placeholder + self.setConfig('BACKLINK_TITLE', self.getConfig("BACKLINK_TITLE").replace("%d", "{}")) + + self.reset() + + def extendMarkdown(self, md): + """ Add pieces to Markdown. """ + md.registerExtension(self) + self.parser = md.parser + self.md = md + # Insert a `blockprocessor` before `ReferencePreprocessor` + md.parser.blockprocessors.register(FootnoteBlockProcessor(self), 'footnote', 17) + + # Insert an inline pattern before `ImageReferencePattern` + FOOTNOTE_RE = r'\[\^([^\]]*)\]' # blah blah [^1] blah + md.inlinePatterns.register(FootnoteInlineProcessor(FOOTNOTE_RE, self), 'footnote', 175) + # Insert a tree-processor that would actually add the footnote div + # This must be before all other tree-processors (i.e., `inline` and + # `codehilite`) so they can run on the the contents of the div. + md.treeprocessors.register(FootnoteTreeprocessor(self), 'footnote', 50) + + # Insert a tree-processor to reorder the footnotes if necessary. This must be after + # `inline` tree-processor so it can access the footnote reference order + # (`self.footnote_order`) that gets populated by the `FootnoteInlineProcessor`. + if not self.getConfig("USE_DEFINITION_ORDER"): + md.treeprocessors.register(FootnoteReorderingProcessor(self), 'footnote-reorder', 19) + + # Insert a tree-processor that will run after inline is done. + # In this tree-processor we want to check our duplicate footnote tracker + # And add additional `backrefs` to the footnote pointing back to the + # duplicated references. + md.treeprocessors.register(FootnotePostTreeprocessor(self), 'footnote-duplicate', 15) + + # Insert a postprocessor after amp_substitute processor + md.postprocessors.register(FootnotePostprocessor(self), 'footnote', 25) + + def reset(self) -> None: + """ Clear footnotes on reset, and prepare for distinct document. """ + self.footnote_order: list[str] = [] + self.footnotes: OrderedDict[str, str] = OrderedDict() + self.unique_prefix += 1 + self.found_refs = {} + self.used_refs = set() + + def unique_ref(self, reference: str, found: bool = False) -> str: + """ Get a unique reference if there are duplicates. """ + if not found: + return reference + + original_ref = reference + while reference in self.used_refs: + ref, rest = reference.split(self.get_separator(), 1) + m = RE_REF_ID.match(ref) + if m: + reference = '%s%d%s%s' % (m.group(1), int(m.group(2))+1, self.get_separator(), rest) + else: + reference = '%s%d%s%s' % (ref, 2, self.get_separator(), rest) + + self.used_refs.add(reference) + if original_ref in self.found_refs: + self.found_refs[original_ref] += 1 + else: + self.found_refs[original_ref] = 1 + return reference + + def findFootnotesPlaceholder( + self, root: etree.Element + ) -> tuple[etree.Element, etree.Element, bool] | None: + """ Return ElementTree Element that contains Footnote placeholder. """ + def finder(element): + for child in element: + if child.text: + if child.text.find(self.getConfig("PLACE_MARKER")) > -1: + return child, element, True + if child.tail: + if child.tail.find(self.getConfig("PLACE_MARKER")) > -1: + return child, element, False + child_res = finder(child) + if child_res is not None: + return child_res + return None + + res = finder(root) + return res + + def setFootnote(self, id: str, text: str) -> None: + """ Store a footnote for later retrieval. """ + self.footnotes[id] = text + + def addFootnoteRef(self, id: str) -> None: + """ Store a footnote reference id in order of appearance. """ + if id not in self.footnote_order: + self.footnote_order.append(id) + + def get_separator(self) -> str: + """ Get the footnote separator. """ + return self.getConfig("SEPARATOR") + + def makeFootnoteId(self, id: str) -> str: + """ Return footnote link id. """ + if self.getConfig("UNIQUE_IDS"): + return 'fn%s%d-%s' % (self.get_separator(), self.unique_prefix, id) + else: + return 'fn{}{}'.format(self.get_separator(), id) + + def makeFootnoteRefId(self, id: str, found: bool = False) -> str: + """ Return footnote back-link id. """ + if self.getConfig("UNIQUE_IDS"): + return self.unique_ref('fnref%s%d-%s' % (self.get_separator(), self.unique_prefix, id), found) + else: + return self.unique_ref('fnref{}{}'.format(self.get_separator(), id), found) + + def makeFootnotesDiv(self, root: etree.Element) -> etree.Element | None: + """ Return `div` of footnotes as `etree` Element. """ + + if not list(self.footnotes.keys()): + return None + + div = etree.Element("div") + div.set('class', 'footnote') + etree.SubElement(div, "hr") + ol = etree.SubElement(div, "ol") + surrogate_parent = etree.Element("div") + + for index, id in enumerate(self.footnotes.keys(), start=1): + li = etree.SubElement(ol, "li") + li.set("id", self.makeFootnoteId(id)) + # Parse footnote with surrogate parent as `li` cannot be used. + # List block handlers have special logic to deal with `li`. + # When we are done parsing, we will copy everything over to `li`. + self.parser.parseChunk(surrogate_parent, self.footnotes[id]) + for el in list(surrogate_parent): + li.append(el) + surrogate_parent.remove(el) + backlink = etree.Element("a") + backlink.set("href", "#" + self.makeFootnoteRefId(id)) + backlink.set("class", "footnote-backref") + backlink.set( + "title", + self.getConfig('BACKLINK_TITLE').format(index) + ) + backlink.text = FN_BACKLINK_TEXT + + if len(li): + node = li[-1] + if node.tag == "p": + node.text = node.text + NBSP_PLACEHOLDER + node.append(backlink) + else: + p = etree.SubElement(li, "p") + p.append(backlink) + return div + + +class FootnoteBlockProcessor(BlockProcessor): + """ Find footnote definitions and store for later use. """ + + RE = re.compile(r'^[ ]{0,3}\[\^([^\]]*)\]:[ ]*(.*)$', re.MULTILINE) + + def __init__(self, footnotes: FootnoteExtension): + super().__init__(footnotes.parser) + self.footnotes = footnotes + + def test(self, parent: etree.Element, block: str) -> bool: + return True + + def run(self, parent: etree.Element, blocks: list[str]) -> bool: + """ Find, set, and remove footnote definitions. """ + block = blocks.pop(0) + + m = self.RE.search(block) + if m: + id = m.group(1) + fn_blocks = [m.group(2)] + + # Handle rest of block + therest = block[m.end():].lstrip('\n') + m2 = self.RE.search(therest) + if m2: + # Another footnote exists in the rest of this block. + # Any content before match is continuation of this footnote, which may be lazily indented. + before = therest[:m2.start()].rstrip('\n') + fn_blocks[0] = '\n'.join([fn_blocks[0], self.detab(before)]).lstrip('\n') + # Add back to blocks everything from beginning of match forward for next iteration. + blocks.insert(0, therest[m2.start():]) + else: + # All remaining lines of block are continuation of this footnote, which may be lazily indented. + fn_blocks[0] = '\n'.join([fn_blocks[0], self.detab(therest)]).strip('\n') + + # Check for child elements in remaining blocks. + fn_blocks.extend(self.detectTabbed(blocks)) + + footnote = "\n\n".join(fn_blocks) + self.footnotes.setFootnote(id, footnote.rstrip()) + + if block[:m.start()].strip(): + # Add any content before match back to blocks as separate block + blocks.insert(0, block[:m.start()].rstrip('\n')) + return True + # No match. Restore block. + blocks.insert(0, block) + return False + + def detectTabbed(self, blocks: list[str]) -> list[str]: + """ Find indented text and remove indent before further processing. + + Returns: + A list of blocks with indentation removed. + """ + fn_blocks = [] + while blocks: + if blocks[0].startswith(' '*4): + block = blocks.pop(0) + # Check for new footnotes within this block and split at new footnote. + m = self.RE.search(block) + if m: + # Another footnote exists in this block. + # Any content before match is continuation of this footnote, which may be lazily indented. + before = block[:m.start()].rstrip('\n') + fn_blocks.append(self.detab(before)) + # Add back to blocks everything from beginning of match forward for next iteration. + blocks.insert(0, block[m.start():]) + # End of this footnote. + break + else: + # Entire block is part of this footnote. + fn_blocks.append(self.detab(block)) + else: + # End of this footnote. + break + return fn_blocks + + def detab(self, block: str) -> str: + """ Remove one level of indent from a block. + + Preserve lazily indented blocks by only removing indent from indented lines. + """ + lines = block.split('\n') + for i, line in enumerate(lines): + if line.startswith(' '*4): + lines[i] = line[4:] + return '\n'.join(lines) + + +class FootnoteInlineProcessor(InlineProcessor): + """ `InlineProcessor` for footnote markers in a document's body text. """ + + def __init__(self, pattern: str, footnotes: FootnoteExtension): + super().__init__(pattern) + self.footnotes = footnotes + + def handleMatch(self, m: re.Match[str], data: str) -> tuple[etree.Element | None, int | None, int | None]: + id = m.group(1) + if id in self.footnotes.footnotes.keys(): + self.footnotes.addFootnoteRef(id) + + if not self.footnotes.getConfig("USE_DEFINITION_ORDER"): + # Order by reference + footnote_num = self.footnotes.footnote_order.index(id) + 1 + else: + # Order by definition + footnote_num = list(self.footnotes.footnotes.keys()).index(id) + 1 + + sup = etree.Element("sup") + a = etree.SubElement(sup, "a") + sup.set('id', self.footnotes.makeFootnoteRefId(id, found=True)) + a.set('href', '#' + self.footnotes.makeFootnoteId(id)) + a.set('class', 'footnote-ref') + a.text = self.footnotes.getConfig("SUPERSCRIPT_TEXT").format(footnote_num) + return sup, m.start(0), m.end(0) + else: + return None, None, None + + +class FootnotePostTreeprocessor(Treeprocessor): + """ Amend footnote div with duplicates. """ + + def __init__(self, footnotes: FootnoteExtension): + self.footnotes = footnotes + + def add_duplicates(self, li: etree.Element, duplicates: int) -> None: + """ Adjust current `li` and add the duplicates: `fnref2`, `fnref3`, etc. """ + for link in li.iter('a'): + # Find the link that needs to be duplicated. + if link.attrib.get('class', '') == 'footnote-backref': + ref, rest = link.attrib['href'].split(self.footnotes.get_separator(), 1) + # Duplicate link the number of times we need to + # and point the to the appropriate references. + links = [] + for index in range(2, duplicates + 1): + sib_link = copy.deepcopy(link) + sib_link.attrib['href'] = '%s%d%s%s' % (ref, index, self.footnotes.get_separator(), rest) + links.append(sib_link) + self.offset += 1 + # Add all the new duplicate links. + el = list(li)[-1] + for link in links: + el.append(link) + break + + def get_num_duplicates(self, li: etree.Element) -> int: + """ Get the number of duplicate refs of the footnote. """ + fn, rest = li.attrib.get('id', '').split(self.footnotes.get_separator(), 1) + link_id = '{}ref{}{}'.format(fn, self.footnotes.get_separator(), rest) + return self.footnotes.found_refs.get(link_id, 0) + + def handle_duplicates(self, parent: etree.Element) -> None: + """ Find duplicate footnotes and format and add the duplicates. """ + for li in list(parent): + # Check number of duplicates footnotes and insert + # additional links if needed. + count = self.get_num_duplicates(li) + if count > 1: + self.add_duplicates(li, count) + + def run(self, root: etree.Element) -> None: + """ Crawl the footnote div and add missing duplicate footnotes. """ + self.offset = 0 + for div in root.iter('div'): + if div.attrib.get('class', '') == 'footnote': + # Footnotes should be under the first ordered list under + # the footnote div. So once we find it, quit. + for ol in div.iter('ol'): + self.handle_duplicates(ol) + break + + +class FootnoteTreeprocessor(Treeprocessor): + """ Build and append footnote div to end of document. """ + + def __init__(self, footnotes: FootnoteExtension): + self.footnotes = footnotes + + def run(self, root: etree.Element) -> None: + footnotesDiv = self.footnotes.makeFootnotesDiv(root) + if footnotesDiv is not None: + result = self.footnotes.findFootnotesPlaceholder(root) + if result: + child, parent, isText = result + ind = list(parent).index(child) + if isText: + parent.remove(child) + parent.insert(ind, footnotesDiv) + else: + parent.insert(ind + 1, footnotesDiv) + child.tail = None + else: + root.append(footnotesDiv) + + +class FootnoteReorderingProcessor(Treeprocessor): + """ Reorder list items in the footnotes div. """ + + def __init__(self, footnotes: FootnoteExtension): + self.footnotes = footnotes + + def run(self, root: etree.Element) -> None: + if not self.footnotes.footnotes: + return + if self.footnotes.footnote_order != list(self.footnotes.footnotes.keys()): + for div in root.iter('div'): + if div.attrib.get('class', '') == 'footnote': + self.reorder_footnotes(div) + break + + def reorder_footnotes(self, parent: etree.Element) -> None: + old_list = parent.find('ol') + parent.remove(old_list) + items = old_list.findall('li') + + def order_by_id(li) -> int: + id = li.attrib.get('id', '').split(self.footnotes.get_separator(), 1)[-1] + return ( + self.footnotes.footnote_order.index(id) + if id in self.footnotes.footnote_order + else len(self.footnotes.footnotes) + ) + + items = sorted(items, key=order_by_id) + + new_list = etree.SubElement(parent, 'ol') + + for index, item in enumerate(items, start=1): + backlink = item.find('.//a[@class="footnote-backref"]') + backlink.set("title", self.footnotes.getConfig("BACKLINK_TITLE").format(index)) + new_list.append(item) + + +class FootnotePostprocessor(Postprocessor): + """ Replace placeholders with html entities. """ + def __init__(self, footnotes: FootnoteExtension): + self.footnotes = footnotes + + def run(self, text: str) -> str: + text = text.replace( + FN_BACKLINK_TEXT, self.footnotes.getConfig("BACKLINK_TEXT") + ) + return text.replace(NBSP_PLACEHOLDER, " ") + + +def makeExtension(**kwargs): # pragma: no cover + """ Return an instance of the `FootnoteExtension` """ + return FootnoteExtension(**kwargs) diff --git a/lib/python3.12/site-packages/markdown/extensions/legacy_attrs.py b/lib/python3.12/site-packages/markdown/extensions/legacy_attrs.py new file mode 100644 index 0000000000000000000000000000000000000000..6641e6ea69e1bb2b3616e46dae9992093329c23d --- /dev/null +++ b/lib/python3.12/site-packages/markdown/extensions/legacy_attrs.py @@ -0,0 +1,71 @@ +# Python Markdown + +# A Python implementation of John Gruber's Markdown. + +# Documentation: https://python-markdown.github.io/ +# GitHub: https://github.com/Python-Markdown/markdown/ +# PyPI: https://pypi.org/project/Markdown/ + +# Started by Manfred Stienstra (http://www.dwerg.net/). +# Maintained for a few years by Yuri Takhteyev (http://www.freewisdom.org). +# Currently maintained by Waylan Limberg (https://github.com/waylan), +# Dmitry Shachnev (https://github.com/mitya57) and Isaac Muse (https://github.com/facelessuser). + +# Copyright 2007-2023 The Python Markdown Project (v. 1.7 and later) +# Copyright 2004, 2005, 2006 Yuri Takhteyev (v. 0.2-1.6b) +# Copyright 2004 Manfred Stienstra (the original version) + +# License: BSD (see LICENSE.md for details). + +""" +An extension to Python Markdown which implements legacy attributes. + +Prior to Python-Markdown version 3.0, the Markdown class had an `enable_attributes` +keyword which was on by default and provided for attributes to be defined for elements +using the format `{@key=value}`. This extension is provided as a replacement for +backward compatibility. New documents should be authored using `attr_lists`. However, +numerous documents exist which have been using the old attribute format for many +years. This extension can be used to continue to render those documents correctly. +""" + +from __future__ import annotations + +import re +from markdown.treeprocessors import Treeprocessor, isString +from markdown.extensions import Extension +from typing import TYPE_CHECKING + +if TYPE_CHECKING: # pragma: no cover + import xml.etree.ElementTree as etree + + +ATTR_RE = re.compile(r'\{@([^\}]*)=([^\}]*)}') # {@id=123} + + +class LegacyAttrs(Treeprocessor): + def run(self, doc: etree.Element) -> None: + """Find and set values of attributes ({@key=value}). """ + for el in doc.iter(): + alt = el.get('alt', None) + if alt is not None: + el.set('alt', self.handleAttributes(el, alt)) + if el.text and isString(el.text): + el.text = self.handleAttributes(el, el.text) + if el.tail and isString(el.tail): + el.tail = self.handleAttributes(el, el.tail) + + def handleAttributes(self, el: etree.Element, txt: str) -> str: + """ Set attributes and return text without definitions. """ + def attributeCallback(match: re.Match[str]): + el.set(match.group(1), match.group(2).replace('\n', ' ')) + return ATTR_RE.sub(attributeCallback, txt) + + +class LegacyAttrExtension(Extension): + def extendMarkdown(self, md): + """ Add `LegacyAttrs` to Markdown instance. """ + md.treeprocessors.register(LegacyAttrs(md), 'legacyattrs', 15) + + +def makeExtension(**kwargs): # pragma: no cover + return LegacyAttrExtension(**kwargs) diff --git a/lib/python3.12/site-packages/markdown/extensions/legacy_em.py b/lib/python3.12/site-packages/markdown/extensions/legacy_em.py new file mode 100644 index 0000000000000000000000000000000000000000..a6f67b7ef3aedc4010ce5ed2b343a18b91115a32 --- /dev/null +++ b/lib/python3.12/site-packages/markdown/extensions/legacy_em.py @@ -0,0 +1,52 @@ +# Legacy Em Extension for Python-Markdown +# ======================================= + +# This extension provides legacy behavior for _connected_words_. + +# Copyright 2015-2018 The Python Markdown Project + +# License: [BSD](https://opensource.org/licenses/bsd-license.php) + +""" +This extension provides legacy behavior for _connected_words_. +""" + +from __future__ import annotations + +from . import Extension +from ..inlinepatterns import UnderscoreProcessor, EmStrongItem, EM_STRONG2_RE, STRONG_EM2_RE +import re + +# _emphasis_ +EMPHASIS_RE = r'(_)([^_]+)\1' + +# __strong__ +STRONG_RE = r'(_{2})(.+?)\1' + +# __strong_em___ +STRONG_EM_RE = r'(_)\1(?!\1)([^_]+?)\1(?!\1)(.+?)\1{3}' + + +class LegacyUnderscoreProcessor(UnderscoreProcessor): + """Emphasis processor for handling strong and em matches inside underscores.""" + + PATTERNS = [ + EmStrongItem(re.compile(EM_STRONG2_RE, re.DOTALL | re.UNICODE), 'double', 'strong,em'), + EmStrongItem(re.compile(STRONG_EM2_RE, re.DOTALL | re.UNICODE), 'double', 'em,strong'), + EmStrongItem(re.compile(STRONG_EM_RE, re.DOTALL | re.UNICODE), 'double2', 'strong,em'), + EmStrongItem(re.compile(STRONG_RE, re.DOTALL | re.UNICODE), 'single', 'strong'), + EmStrongItem(re.compile(EMPHASIS_RE, re.DOTALL | re.UNICODE), 'single', 'em') + ] + + +class LegacyEmExtension(Extension): + """ Add legacy_em extension to Markdown class.""" + + def extendMarkdown(self, md): + """ Modify inline patterns. """ + md.inlinePatterns.register(LegacyUnderscoreProcessor(r'_'), 'em_strong2', 50) + + +def makeExtension(**kwargs): # pragma: no cover + """ Return an instance of the `LegacyEmExtension` """ + return LegacyEmExtension(**kwargs) diff --git a/lib/python3.12/site-packages/markdown/extensions/md_in_html.py b/lib/python3.12/site-packages/markdown/extensions/md_in_html.py new file mode 100644 index 0000000000000000000000000000000000000000..7eada6ffccb8cc79f5d1f296353f6cef3eacdd72 --- /dev/null +++ b/lib/python3.12/site-packages/markdown/extensions/md_in_html.py @@ -0,0 +1,440 @@ +# Python-Markdown Markdown in HTML Extension +# =============================== + +# An implementation of [PHP Markdown Extra](http://michelf.com/projects/php-markdown/extra/)'s +# parsing of Markdown syntax in raw HTML. + +# See https://Python-Markdown.github.io/extensions/raw_html +# for documentation. + +# Copyright The Python Markdown Project + +# License: [BSD](https://opensource.org/licenses/bsd-license.php) + +""" +Parse Markdown syntax within raw HTML. +Based on the implementation in [PHP Markdown Extra](http://michelf.com/projects/php-markdown/extra/). + +See the [documentation](https://Python-Markdown.github.io/extensions/raw_html) +for details. +""" + +from __future__ import annotations + +from . import Extension +from ..blockprocessors import BlockProcessor +from ..preprocessors import Preprocessor +from ..postprocessors import RawHtmlPostprocessor +from .. import util +from ..htmlparser import HTMLExtractor, blank_line_re +import xml.etree.ElementTree as etree +from typing import TYPE_CHECKING, Literal, Mapping + +if TYPE_CHECKING: # pragma: no cover + from markdown import Markdown + + +class HTMLExtractorExtra(HTMLExtractor): + """ + Override `HTMLExtractor` and create `etree` `Elements` for any elements which should have content parsed as + Markdown. + """ + + def __init__(self, md: Markdown, *args, **kwargs): + # All block-level tags. + self.block_level_tags = set(md.block_level_elements.copy()) + # Block-level tags in which the content only gets span level parsing + self.span_tags = set( + ['address', 'dd', 'dt', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'legend', 'li', 'p', 'summary', 'td', 'th'] + ) + # Block-level tags which never get their content parsed. + self.raw_tags = set(['canvas', 'math', 'option', 'pre', 'script', 'style', 'textarea']) + + super().__init__(md, *args, **kwargs) + + # Block-level tags in which the content gets parsed as blocks + self.block_tags = set(self.block_level_tags) - (self.span_tags | self.raw_tags | self.empty_tags) + self.span_and_blocks_tags = self.block_tags | self.span_tags + + def reset(self): + """Reset this instance. Loses all unprocessed data.""" + self.mdstack: list[str] = [] # When markdown=1, stack contains a list of tags + self.treebuilder = etree.TreeBuilder() + self.mdstate: list[Literal['block', 'span', 'off', None]] = [] + self.mdstarted: list[bool] = [] + super().reset() + + def close(self): + """Handle any buffered data.""" + super().close() + # Handle any unclosed tags. + if self.mdstack: + # Close the outermost parent. `handle_endtag` will close all unclosed children. + self.handle_endtag(self.mdstack[0]) + + def get_element(self) -> etree.Element: + """ Return element from `treebuilder` and reset `treebuilder` for later use. """ + element = self.treebuilder.close() + self.treebuilder = etree.TreeBuilder() + return element + + def get_state(self, tag, attrs: Mapping[str, str]) -> Literal['block', 'span', 'off', None]: + """ Return state from tag and `markdown` attribute. One of 'block', 'span', or 'off'. """ + md_attr = attrs.get('markdown', '0') + if md_attr == 'markdown': + # `<tag markdown>` is the same as `<tag markdown='1'>`. + md_attr = '1' + parent_state = self.mdstate[-1] if self.mdstate else None + if parent_state == 'off' or (parent_state == 'span' and md_attr != '0'): + # Only use the parent state if it is more restrictive than the markdown attribute. + md_attr = parent_state + if ((md_attr == '1' and tag in self.block_tags) or + (md_attr == 'block' and tag in self.span_and_blocks_tags)): + return 'block' + elif ((md_attr == '1' and tag in self.span_tags) or + (md_attr == 'span' and tag in self.span_and_blocks_tags)): + return 'span' + elif tag in self.block_level_tags: + return 'off' + else: # pragma: no cover + return None + + def handle_starttag(self, tag, attrs): + # Handle tags that should always be empty and do not specify a closing tag + if tag in self.empty_tags and (self.at_line_start() or self.intail): + attrs = {key: value if value is not None else key for key, value in attrs} + if "markdown" in attrs: + attrs.pop('markdown') + element = etree.Element(tag, attrs) + data = etree.tostring(element, encoding='unicode', method='html') + else: + data = self.get_starttag_text() + self.handle_empty_tag(data, True) + return + + if ( + tag in self.block_level_tags and + (self.at_line_start() or self.intail or self.mdstarted and self.mdstarted[-1]) + ): + # Valueless attribute (ex: `<tag checked>`) results in `[('checked', None)]`. + # Convert to `{'checked': 'checked'}`. + attrs = {key: value if value is not None else key for key, value in attrs} + state = self.get_state(tag, attrs) + if self.inraw or (state in [None, 'off'] and not self.mdstack): + # fall back to default behavior + attrs.pop('markdown', None) + super().handle_starttag(tag, attrs) + else: + if 'p' in self.mdstack and tag in self.block_level_tags: + # Close unclosed 'p' tag + self.handle_endtag('p') + self.mdstate.append(state) + self.mdstack.append(tag) + self.mdstarted.append(True) + attrs['markdown'] = state + self.treebuilder.start(tag, attrs) + + else: + # Span level tag + if self.inraw: + super().handle_starttag(tag, attrs) + else: + text = self.get_starttag_text() + if self.mdstate and self.mdstate[-1] == "off": + self.handle_data(self.md.htmlStash.store(text)) + else: + self.handle_data(text) + if tag in self.CDATA_CONTENT_ELEMENTS: + # This is presumably a standalone tag in a code span (see #1036). + self.clear_cdata_mode() + + def handle_endtag(self, tag): + if tag in self.block_level_tags: + if self.inraw: + super().handle_endtag(tag) + elif tag in self.mdstack: + # Close element and any unclosed children + while self.mdstack: + item = self.mdstack.pop() + self.mdstate.pop() + self.mdstarted.pop() + self.treebuilder.end(item) + if item == tag: + break + if not self.mdstack: + # Last item in stack is closed. Stash it + element = self.get_element() + # Get last entry to see if it ends in newlines + # If it is an element, assume there is no newlines + item = self.cleandoc[-1] if self.cleandoc else '' + # If we only have one newline before block element, add another + if not item.endswith('\n\n') and item.endswith('\n'): + self.cleandoc.append('\n') + + # Flatten the HTML structure of "markdown" blocks such that when they + # get parsed, content will be parsed similar inside the blocks as it + # does outside the block. Having real HTML elements in the tree before + # the content adjacent content is processed can cause unpredictable + # issues for extensions. + current = element + last = [] + while current is not None: + for child in list(current): + current.remove(child) + text = current.text if current.text is not None else '' + tail = child.tail if child.tail is not None else '' + child.tail = None + state = child.attrib.get('markdown', 'off') + + # Add a newline to tail if it is not just a trailing newline + if tail != '\n': + tail = '\n' + tail.rstrip('\n') + + # Ensure there is an empty new line between blocks + if not text.endswith('\n\n'): + text = text.rstrip('\n') + '\n\n' + + # Process the block nested under the span appropriately + if state in ('span', 'block'): + current.text = f'{text}{self.md.htmlStash.store(child)}{tail}' + last.append(child) + else: + # Non-Markdown HTML will not be recursively parsed for Markdown, + # so we can just remove markers and leave them unflattened. + # Additionally, we don't need to append to our list for further + # processing. + child.attrib.pop('markdown') + [c.attrib.pop('markdown', None) for c in child.iter()] + current.text = f'{text}{self.md.htmlStash.store(child)}{tail}' + # Target the child elements that have been expanded. + current = last.pop(0) if last else None + + self.cleandoc.append(self.md.htmlStash.store(element)) + self.cleandoc.append('\n\n') + self.state = [] + # Check if element has a tail + if not blank_line_re.match( + self.rawdata[self.line_offset + self.offset + len(self.get_endtag_text(tag)):]): + # More content exists after `endtag`. + self.intail = True + else: + # Treat orphan closing tag as a span level tag. + text = self.get_endtag_text(tag) + if self.mdstate and self.mdstate[-1] == "off": + self.handle_data(self.md.htmlStash.store(text)) + else: + self.handle_data(text) + else: + # Span level tag + if self.inraw: + super().handle_endtag(tag) + else: + text = self.get_endtag_text(tag) + if self.mdstate and self.mdstate[-1] == "off": + self.handle_data(self.md.htmlStash.store(text)) + else: + self.handle_data(text) + + def handle_startendtag(self, tag, attrs): + if tag in self.empty_tags: + attrs = {key: value if value is not None else key for key, value in attrs} + if "markdown" in attrs: + attrs.pop('markdown') + element = etree.Element(tag, attrs) + data = etree.tostring(element, encoding='unicode', method='html') + else: + data = self.get_starttag_text() + else: + data = self.get_starttag_text() + self.handle_empty_tag(data, is_block=self.md.is_block_level(tag)) + + def handle_data(self, data): + if self.intail and '\n' in data: + self.intail = False + if self.inraw or not self.mdstack: + super().handle_data(data) + else: + self.mdstarted[-1] = False + self.treebuilder.data(data) + + def handle_empty_tag(self, data, is_block): + if self.inraw or not self.mdstack: + super().handle_empty_tag(data, is_block) + else: + if self.at_line_start() and is_block: + self.handle_data('\n' + self.md.htmlStash.store(data) + '\n\n') + elif self.mdstate and self.mdstate[-1] == "off": + self.handle_data(self.md.htmlStash.store(data)) + else: + self.handle_data(data) + + def parse_pi(self, i: int) -> int: + if self.at_line_start() or self.intail or self.mdstack: + # The same override exists in `HTMLExtractor` without the check + # for `mdstack`. Therefore, use parent of `HTMLExtractor` instead. + return super(HTMLExtractor, self).parse_pi(i) + # This is not the beginning of a raw block so treat as plain data + # and avoid consuming any tags which may follow (see #1066). + self.handle_data('<?') + return i + 2 + + def parse_html_declaration(self, i: int) -> int: + if self.at_line_start() or self.intail or self.mdstack: + if self.rawdata[i:i+3] == '<![' and not self.rawdata[i:i+9] == '<![CDATA[': + # We have encountered the bug in #1534 (Python bug `gh-77057`). + # Provide an override until we drop support for Python < 3.13. + result = self.parse_bogus_comment(i) + if result == -1: + self.handle_data(self.rawdata[i:i + 1]) + return i + 1 + return result + # The same override exists in `HTMLExtractor` without the check + # for `mdstack`. Therefore, use parent of `HTMLExtractor` instead. + return super(HTMLExtractor, self).parse_html_declaration(i) + # This is not the beginning of a raw block so treat as plain data + # and avoid consuming any tags which may follow (see #1066). + self.handle_data('<!') + return i + 2 + + +class HtmlBlockPreprocessor(Preprocessor): + """Remove html blocks from the text and store them for later retrieval.""" + + def run(self, lines: list[str]) -> list[str]: + source = '\n'.join(lines) + parser = HTMLExtractorExtra(self.md) + parser.feed(source) + parser.close() + return ''.join(parser.cleandoc).split('\n') + + +class MarkdownInHtmlProcessor(BlockProcessor): + """Process Markdown Inside HTML Blocks which have been stored in the `HtmlStash`.""" + + def test(self, parent: etree.Element, block: str) -> bool: + # Always return True. `run` will return `False` it not a valid match. + return True + + def parse_element_content(self, element: etree.Element) -> None: + """ + Recursively parse the text content of an `etree` Element as Markdown. + + Any block level elements generated from the Markdown will be inserted as children of the element in place + of the text content. All `markdown` attributes are removed. For any elements in which Markdown parsing has + been disabled, the text content of it and its children are wrapped in an `AtomicString`. + """ + + md_attr = element.attrib.pop('markdown', 'off') + + if md_attr == 'block': + # Parse the block elements content as Markdown + if element.text: + block = element.text.rstrip('\n') + element.text = '' + self.parser.parseBlocks(element, block.split('\n\n')) + + elif md_attr == 'span': + # Span elements need to be recursively processed for block elements and raw HTML + # as their content is not normally accessed by block processors, so expand stashed + # HTML under the span. Span content itself will not be parsed here, but will await + # the inline parser. + block = element.text if element.text is not None else '' + element.text = '' + child = None + start = 0 + + # Search the content for HTML placeholders and process the elements + for m in util.HTML_PLACEHOLDER_RE.finditer(block): + index = int(m.group(1)) + el = self.parser.md.htmlStash.rawHtmlBlocks[index] + end = m.start() + + if isinstance(el, etree.Element): + # Replace the placeholder with the element and process it. + # Content after the placeholder should be attached to the tail. + if child is None: + element.text += block[start:end] + else: + child.tail += block[start:end] + element.append(el) + self.parse_element_content(el) + child = el + if child.tail is None: + child.tail = '' + self.parser.md.htmlStash.rawHtmlBlocks.pop(index) + self.parser.md.htmlStash.rawHtmlBlocks.insert(index, '') + + else: + # Not an element object, so insert content back into the element + if child is None: + element.text += block[start:end] + else: + child.tail += block[start:end] + start = end + + # Insert anything left after last element + if child is None: + element.text += block[start:] + else: + child.tail += block[start:] + + else: + # Disable inline parsing for everything else + if element.text is None: + element.text = '' + element.text = util.AtomicString(element.text) + for child in list(element): + self.parse_element_content(child) + if child.tail: + child.tail = util.AtomicString(child.tail) + + def run(self, parent: etree.Element, blocks: list[str]) -> bool: + m = util.HTML_PLACEHOLDER_RE.match(blocks[0]) + if m: + index = int(m.group(1)) + element = self.parser.md.htmlStash.rawHtmlBlocks[index] + if isinstance(element, etree.Element): + # We have a matched element. Process it. + block = blocks.pop(0) + parent.append(element) + self.parse_element_content(element) + # Cleanup stash. Replace element with empty string to avoid confusing postprocessor. + self.parser.md.htmlStash.rawHtmlBlocks.pop(index) + self.parser.md.htmlStash.rawHtmlBlocks.insert(index, '') + content = block[m.end(0):] + # Ensure the rest of the content gets handled + if content: + blocks.insert(0, content) + # Confirm the match to the `blockparser`. + return True + # No match found. + return False + + +class MarkdownInHTMLPostprocessor(RawHtmlPostprocessor): + def stash_to_string(self, text: str | etree.Element) -> str: + """ Override default to handle any `etree` elements still in the stash. """ + if isinstance(text, etree.Element): + return self.md.serializer(text) + else: + return str(text) + + +class MarkdownInHtmlExtension(Extension): + """Add Markdown parsing in HTML to Markdown class.""" + + def extendMarkdown(self, md): + """ Register extension instances. """ + + # Replace raw HTML preprocessor + md.preprocessors.register(HtmlBlockPreprocessor(md), 'html_block', 20) + # Add `blockprocessor` which handles the placeholders for `etree` elements + md.parser.blockprocessors.register( + MarkdownInHtmlProcessor(md.parser), 'markdown_block', 105 + ) + # Replace raw HTML postprocessor + md.postprocessors.register(MarkdownInHTMLPostprocessor(md), 'raw_html', 30) + + +def makeExtension(**kwargs): # pragma: no cover + return MarkdownInHtmlExtension(**kwargs) diff --git a/lib/python3.12/site-packages/markdown/extensions/meta.py b/lib/python3.12/site-packages/markdown/extensions/meta.py new file mode 100644 index 0000000000000000000000000000000000000000..9976b8de28660e7435048e53b82e313889d7412e --- /dev/null +++ b/lib/python3.12/site-packages/markdown/extensions/meta.py @@ -0,0 +1,86 @@ +# Meta Data Extension for Python-Markdown +# ======================================= + +# This extension adds Meta Data handling to markdown. + +# See https://Python-Markdown.github.io/extensions/meta_data +# for documentation. + +# Original code Copyright 2007-2008 [Waylan Limberg](https://github.com/waylan). + +# All changes Copyright 2008-2014 The Python Markdown Project + +# License: [BSD](https://opensource.org/licenses/bsd-license.php) + +""" +This extension adds Meta Data handling to markdown. + +See the [documentation](https://Python-Markdown.github.io/extensions/meta_data) +for details. +""" + +from __future__ import annotations + +from . import Extension +from ..preprocessors import Preprocessor +import re +import logging +from typing import Any + +log = logging.getLogger('MARKDOWN') + +# Global Vars +META_RE = re.compile(r'^[ ]{0,3}(?P<key>[A-Za-z0-9_-]+):\s*(?P<value>.*)') +META_MORE_RE = re.compile(r'^[ ]{4,}(?P<value>.*)') +BEGIN_RE = re.compile(r'^-{3}(\s.*)?') +END_RE = re.compile(r'^(-{3}|\.{3})(\s.*)?') + + +class MetaExtension (Extension): + """ Meta-Data extension for Python-Markdown. """ + + def extendMarkdown(self, md): + """ Add `MetaPreprocessor` to Markdown instance. """ + md.registerExtension(self) + self.md = md + md.preprocessors.register(MetaPreprocessor(md), 'meta', 27) + + def reset(self) -> None: + self.md.Meta = {} + + +class MetaPreprocessor(Preprocessor): + """ Get Meta-Data. """ + + def run(self, lines: list[str]) -> list[str]: + """ Parse Meta-Data and store in Markdown.Meta. """ + meta: dict[str, Any] = {} + key = None + if lines and BEGIN_RE.match(lines[0]): + lines.pop(0) + while lines: + line = lines.pop(0) + m1 = META_RE.match(line) + if line.strip() == '' or END_RE.match(line): + break # blank line or end of YAML header - done + if m1: + key = m1.group('key').lower().strip() + value = m1.group('value').strip() + try: + meta[key].append(value) + except KeyError: + meta[key] = [value] + else: + m2 = META_MORE_RE.match(line) + if m2 and key: + # Add another line to existing key + meta[key].append(m2.group('value').strip()) + else: + lines.insert(0, line) + break # no meta data - done + self.md.Meta = meta + return lines + + +def makeExtension(**kwargs): # pragma: no cover + return MetaExtension(**kwargs) diff --git a/lib/python3.12/site-packages/markdown/extensions/nl2br.py b/lib/python3.12/site-packages/markdown/extensions/nl2br.py new file mode 100644 index 0000000000000000000000000000000000000000..d4729202570699d590b1c88f20e7778ca05d6249 --- /dev/null +++ b/lib/python3.12/site-packages/markdown/extensions/nl2br.py @@ -0,0 +1,41 @@ +# `NL2BR` Extension +# =============== + +# A Python-Markdown extension to treat newlines as hard breaks; like +# GitHub-flavored Markdown does. + +# See https://Python-Markdown.github.io/extensions/nl2br +# for documentation. + +# Original code Copyright 2011 [Brian Neal](https://deathofagremmie.com/) + +# All changes Copyright 2011-2014 The Python Markdown Project + +# License: [BSD](https://opensource.org/licenses/bsd-license.php) + +""" +A Python-Markdown extension to treat newlines as hard breaks. +Similar to GitHub-flavored Markdown's behavior. + +See the [documentation](https://Python-Markdown.github.io/extensions/nl2br) +for details. +""" + +from __future__ import annotations + +from . import Extension +from ..inlinepatterns import SubstituteTagInlineProcessor + +BR_RE = r'\n' + + +class Nl2BrExtension(Extension): + + def extendMarkdown(self, md): + """ Add a `SubstituteTagInlineProcessor` to Markdown. """ + br_tag = SubstituteTagInlineProcessor(BR_RE, 'br') + md.inlinePatterns.register(br_tag, 'nl', 5) + + +def makeExtension(**kwargs): # pragma: no cover + return Nl2BrExtension(**kwargs) diff --git a/lib/python3.12/site-packages/markdown/extensions/sane_lists.py b/lib/python3.12/site-packages/markdown/extensions/sane_lists.py new file mode 100644 index 0000000000000000000000000000000000000000..5e05459fbafe97da9164aca627bb4052a9147d54 --- /dev/null +++ b/lib/python3.12/site-packages/markdown/extensions/sane_lists.py @@ -0,0 +1,69 @@ +# Sane List Extension for Python-Markdown +# ======================================= + +# Modify the behavior of Lists in Python-Markdown to act in a sane manor. + +# See https://Python-Markdown.github.io/extensions/sane_lists +# for documentation. + +# Original code Copyright 2011 [Waylan Limberg](https://github.com/waylan) + +# All changes Copyright 2011-2014 The Python Markdown Project + +# License: [BSD](https://opensource.org/licenses/bsd-license.php) + +""" +Modify the behavior of Lists in Python-Markdown to act in a sane manor. + +See [documentation](https://Python-Markdown.github.io/extensions/sane_lists) +for details. +""" + +from __future__ import annotations + +from . import Extension +from ..blockprocessors import OListProcessor, UListProcessor +import re +from typing import TYPE_CHECKING + +if TYPE_CHECKING: # pragma: no cover + from .. import blockparser + + +class SaneOListProcessor(OListProcessor): + """ Override `SIBLING_TAGS` to not include `ul` and set `LAZY_OL` to `False`. """ + + SIBLING_TAGS = ['ol'] + """ Exclude `ul` from list of siblings. """ + LAZY_OL = False + """ Disable lazy list behavior. """ + + def __init__(self, parser: blockparser.BlockParser): + super().__init__(parser) + self.CHILD_RE = re.compile(r'^[ ]{0,%d}((\d+\.))[ ]+(.*)' % + (self.tab_length - 1)) + + +class SaneUListProcessor(UListProcessor): + """ Override `SIBLING_TAGS` to not include `ol`. """ + + SIBLING_TAGS = ['ul'] + """ Exclude `ol` from list of siblings. """ + + def __init__(self, parser: blockparser.BlockParser): + super().__init__(parser) + self.CHILD_RE = re.compile(r'^[ ]{0,%d}(([*+-]))[ ]+(.*)' % + (self.tab_length - 1)) + + +class SaneListExtension(Extension): + """ Add sane lists to Markdown. """ + + def extendMarkdown(self, md): + """ Override existing Processors. """ + md.parser.blockprocessors.register(SaneOListProcessor(md.parser), 'olist', 40) + md.parser.blockprocessors.register(SaneUListProcessor(md.parser), 'ulist', 30) + + +def makeExtension(**kwargs): # pragma: no cover + return SaneListExtension(**kwargs) diff --git a/lib/python3.12/site-packages/markdown/extensions/smarty.py b/lib/python3.12/site-packages/markdown/extensions/smarty.py new file mode 100644 index 0000000000000000000000000000000000000000..c1817e634d72ec20b28645859945de347a28e55b --- /dev/null +++ b/lib/python3.12/site-packages/markdown/extensions/smarty.py @@ -0,0 +1,280 @@ +# Smarty extension for Python-Markdown +# ==================================== + +# Adds conversion of ASCII dashes, quotes and ellipses to their HTML +# entity equivalents. + +# See https://Python-Markdown.github.io/extensions/smarty +# for documentation. + +# Author: 2013, Dmitry Shachnev <mitya57@gmail.com> + +# All changes Copyright 2013-2014 The Python Markdown Project + +# License: [BSD](https://opensource.org/licenses/bsd-license.php) + +# SmartyPants license: + +# Copyright (c) 2003 John Gruber <https://daringfireball.net/> +# All rights reserved. + +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: + +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. + +# * Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. + +# * Neither the name "SmartyPants" nor the names of its contributors +# may be used to endorse or promote products derived from this +# software without specific prior written permission. + +# This software is provided by the copyright holders and contributors "as +# is" and any express or implied warranties, including, but not limited +# to, the implied warranties of merchantability and fitness for a +# particular purpose are disclaimed. In no event shall the copyright +# owner or contributors be liable for any direct, indirect, incidental, +# special, exemplary, or consequential damages (including, but not +# limited to, procurement of substitute goods or services; loss of use, +# data, or profits; or business interruption) however caused and on any +# theory of liability, whether in contract, strict liability, or tort +# (including negligence or otherwise) arising in any way out of the use +# of this software, even if advised of the possibility of such damage. + + +# `smartypants.py` license: + +# `smartypants.py` is a derivative work of SmartyPants. +# Copyright (c) 2004, 2007 Chad Miller <http://web.chad.org/> + +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: + +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. + +# * Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in +# the documentation and/or other materials provided with the +# distribution. + +# This software is provided by the copyright holders and contributors "as +# is" and any express or implied warranties, including, but not limited +# to, the implied warranties of merchantability and fitness for a +# particular purpose are disclaimed. In no event shall the copyright +# owner or contributors be liable for any direct, indirect, incidental, +# special, exemplary, or consequential damages (including, but not +# limited to, procurement of substitute goods or services; loss of use, +# data, or profits; or business interruption) however caused and on any +# theory of liability, whether in contract, strict liability, or tort +# (including negligence or otherwise) arising in any way out of the use +# of this software, even if advised of the possibility of such damage. + +""" +Convert ASCII dashes, quotes and ellipses to their HTML entity equivalents. + +See the [documentation](https://Python-Markdown.github.io/extensions/smarty) +for details. +""" + +from __future__ import annotations + +from . import Extension +from ..inlinepatterns import HtmlInlineProcessor, HTML_RE +from ..treeprocessors import InlineProcessor +from ..util import Registry +from typing import TYPE_CHECKING, Sequence + +if TYPE_CHECKING: # pragma: no cover + from markdown import Markdown + from .. import inlinepatterns + import re + import xml.etree.ElementTree as etree + +# Constants for quote education. +punctClass = r"""[!"#\$\%'()*+,-.\/:;<=>?\@\[\\\]\^_`{|}~]""" +endOfWordClass = r"[\s.,;:!?)]" +closeClass = r"[^\ \t\r\n\[\{\(\-\u0002\u0003]" + +openingQuotesBase = ( + r'(\s' # a whitespace char + r'| ' # or a non-breaking space entity + r'|--' # or dashes + r'|–|—' # or Unicode + r'|&[mn]dash;' # or named dash entities + r'|–|—' # or decimal entities + r')' +) + +substitutions = { + 'mdash': '—', + 'ndash': '–', + 'ellipsis': '…', + 'left-angle-quote': '«', + 'right-angle-quote': '»', + 'left-single-quote': '‘', + 'right-single-quote': '’', + 'left-double-quote': '“', + 'right-double-quote': '”', +} + + +# Special case if the very first character is a quote +# followed by punctuation at a non-word-break. Close the quotes by brute force: +singleQuoteStartRe = r"^'(?=%s\B)" % punctClass +doubleQuoteStartRe = r'^"(?=%s\B)' % punctClass + +# Special case for double sets of quotes, e.g.: +# <p>He said, "'Quoted' words in a larger quote."</p> +doubleQuoteSetsRe = r""""'(?=\w)""" +singleQuoteSetsRe = r"""'"(?=\w)""" +doubleQuoteSetsRe2 = r'(?<=%s)\'"' % closeClass +singleQuoteSetsRe2 = r"(?<=%s)\"'" % closeClass + +# Special case for decade abbreviations (the '80s): +decadeAbbrRe = r"(?<!\w)'(?=\d{2}s)" + +# Get most opening double quotes: +openingDoubleQuotesRegex = r'%s"(?=\w)' % openingQuotesBase + +# Double closing quotes: +closingDoubleQuotesRegex = r'"(?=\s)' +closingDoubleQuotesRegex2 = r'(?<=%s)"' % closeClass + +# Get most opening single quotes: +openingSingleQuotesRegex = r"%s'(?=\w)" % openingQuotesBase + +# Single closing quotes: +closingSingleQuotesRegex = r"(?<=%s)'(?!\s|s\b|\d)" % closeClass +closingSingleQuotesRegex2 = r"'(\s|s\b)" + +# All remaining quotes should be opening ones +remainingSingleQuotesRegex = r"'" +remainingDoubleQuotesRegex = r'"' + +HTML_STRICT_RE = HTML_RE + r'(?!\>)' + + +class SubstituteTextPattern(HtmlInlineProcessor): + def __init__(self, pattern: str, replace: Sequence[int | str | etree.Element], md: Markdown): + """ Replaces matches with some text. """ + HtmlInlineProcessor.__init__(self, pattern) + self.replace = replace + self.md = md + + def handleMatch(self, m: re.Match[str], data: str) -> tuple[str, int, int]: + result = '' + for part in self.replace: + if isinstance(part, int): + result += m.group(part) + else: + result += self.md.htmlStash.store(part) + return result, m.start(0), m.end(0) + + +class SmartyExtension(Extension): + """ Add Smarty to Markdown. """ + def __init__(self, **kwargs): + self.config = { + 'smart_quotes': [True, 'Educate quotes'], + 'smart_angled_quotes': [False, 'Educate angled quotes'], + 'smart_dashes': [True, 'Educate dashes'], + 'smart_ellipses': [True, 'Educate ellipses'], + 'substitutions': [{}, 'Overwrite default substitutions'], + } + """ Default configuration options. """ + super().__init__(**kwargs) + self.substitutions: dict[str, str] = dict(substitutions) + self.substitutions.update(self.getConfig('substitutions', default={})) + + def _addPatterns( + self, + md: Markdown, + patterns: Sequence[tuple[str, Sequence[int | str | etree.Element]]], + serie: str, + priority: int, + ): + for ind, pattern in enumerate(patterns): + pattern += (md,) + pattern = SubstituteTextPattern(*pattern) + name = 'smarty-%s-%d' % (serie, ind) + self.inlinePatterns.register(pattern, name, priority-ind) + + def educateDashes(self, md: Markdown) -> None: + emDashesPattern = SubstituteTextPattern( + r'(?<!-)---(?!-)', (self.substitutions['mdash'],), md + ) + enDashesPattern = SubstituteTextPattern( + r'(?<!-)--(?!-)', (self.substitutions['ndash'],), md + ) + self.inlinePatterns.register(emDashesPattern, 'smarty-em-dashes', 50) + self.inlinePatterns.register(enDashesPattern, 'smarty-en-dashes', 45) + + def educateEllipses(self, md: Markdown) -> None: + ellipsesPattern = SubstituteTextPattern( + r'(?<!\.)\.{3}(?!\.)', (self.substitutions['ellipsis'],), md + ) + self.inlinePatterns.register(ellipsesPattern, 'smarty-ellipses', 10) + + def educateAngledQuotes(self, md: Markdown) -> None: + leftAngledQuotePattern = SubstituteTextPattern( + r'\<\<', (self.substitutions['left-angle-quote'],), md + ) + rightAngledQuotePattern = SubstituteTextPattern( + r'\>\>', (self.substitutions['right-angle-quote'],), md + ) + self.inlinePatterns.register(leftAngledQuotePattern, 'smarty-left-angle-quotes', 40) + self.inlinePatterns.register(rightAngledQuotePattern, 'smarty-right-angle-quotes', 35) + + def educateQuotes(self, md: Markdown) -> None: + lsquo = self.substitutions['left-single-quote'] + rsquo = self.substitutions['right-single-quote'] + ldquo = self.substitutions['left-double-quote'] + rdquo = self.substitutions['right-double-quote'] + patterns = ( + (singleQuoteStartRe, (rsquo,)), + (doubleQuoteStartRe, (rdquo,)), + (doubleQuoteSetsRe, (ldquo + lsquo,)), + (singleQuoteSetsRe, (lsquo + ldquo,)), + (doubleQuoteSetsRe2, (rsquo + rdquo,)), + (singleQuoteSetsRe2, (rdquo + rsquo,)), + (decadeAbbrRe, (rsquo,)), + (openingSingleQuotesRegex, (1, lsquo)), + (closingSingleQuotesRegex, (rsquo,)), + (closingSingleQuotesRegex2, (rsquo, 1)), + (remainingSingleQuotesRegex, (lsquo,)), + (openingDoubleQuotesRegex, (1, ldquo)), + (closingDoubleQuotesRegex, (rdquo,)), + (closingDoubleQuotesRegex2, (rdquo,)), + (remainingDoubleQuotesRegex, (ldquo,)) + ) + self._addPatterns(md, patterns, 'quotes', 30) + + def extendMarkdown(self, md): + configs = self.getConfigs() + self.inlinePatterns: Registry[inlinepatterns.InlineProcessor] = Registry() + if configs['smart_ellipses']: + self.educateEllipses(md) + if configs['smart_quotes']: + self.educateQuotes(md) + if configs['smart_angled_quotes']: + self.educateAngledQuotes(md) + # Override `HTML_RE` from `inlinepatterns.py` so that it does not + # process tags with duplicate closing quotes. + md.inlinePatterns.register(HtmlInlineProcessor(HTML_STRICT_RE, md), 'html', 90) + if configs['smart_dashes']: + self.educateDashes(md) + inlineProcessor = InlineProcessor(md) + inlineProcessor.inlinePatterns = self.inlinePatterns + md.treeprocessors.register(inlineProcessor, 'smarty', 6) + md.ESCAPED_CHARS.extend(['"', "'"]) + + +def makeExtension(**kwargs): # pragma: no cover + return SmartyExtension(**kwargs) diff --git a/lib/python3.12/site-packages/markdown/extensions/tables.py b/lib/python3.12/site-packages/markdown/extensions/tables.py new file mode 100644 index 0000000000000000000000000000000000000000..678048e61b13af29df398d48a283d21089d79a2d --- /dev/null +++ b/lib/python3.12/site-packages/markdown/extensions/tables.py @@ -0,0 +1,248 @@ +# Tables Extension for Python-Markdown +# ==================================== + +# Adds parsing of tables to Python-Markdown. + +# See https://Python-Markdown.github.io/extensions/tables +# for documentation. + +# Original code Copyright 2009 [Waylan Limberg](https://github.com/waylan) + +# All changes Copyright 2008-2014 The Python Markdown Project + +# License: [BSD](https://opensource.org/licenses/bsd-license.php) + +""" +Adds parsing of tables to Python-Markdown. + +See the [documentation](https://Python-Markdown.github.io/extensions/tables) +for details. +""" + +from __future__ import annotations + +from . import Extension +from ..blockprocessors import BlockProcessor +import xml.etree.ElementTree as etree +import re +from typing import TYPE_CHECKING, Any, Sequence + +if TYPE_CHECKING: # pragma: no cover + from .. import blockparser + +PIPE_NONE = 0 +PIPE_LEFT = 1 +PIPE_RIGHT = 2 + + +class TableProcessor(BlockProcessor): + """ Process Tables. """ + + RE_CODE_PIPES = re.compile(r'(?:(\\\\)|(\\`+)|(`+)|(\\\|)|(\|))') + RE_END_BORDER = re.compile(r'(?<!\\)(?:\\\\)*\|$') + + def __init__(self, parser: blockparser.BlockParser, config: dict[str, Any]): + self.border: bool | int = False + self.separator: Sequence[str] = '' + self.config = config + + super().__init__(parser) + + def test(self, parent: etree.Element, block: str) -> bool: + """ + Ensure first two rows (column header and separator row) are valid table rows. + + Keep border check and separator row do avoid repeating the work. + """ + is_table = False + rows = [row.strip(' ') for row in block.split('\n')] + if len(rows) > 1: + header0 = rows[0] + self.border = PIPE_NONE + if header0.startswith('|'): + self.border |= PIPE_LEFT + if self.RE_END_BORDER.search(header0) is not None: + self.border |= PIPE_RIGHT + row = self._split_row(header0) + row0_len = len(row) + is_table = row0_len > 1 + + # Each row in a single column table needs at least one pipe. + if not is_table and row0_len == 1 and self.border: + for index in range(1, len(rows)): + is_table = rows[index].startswith('|') + if not is_table: + is_table = self.RE_END_BORDER.search(rows[index]) is not None + if not is_table: + break + + if is_table: + row = self._split_row(rows[1]) + is_table = (len(row) == row0_len) and set(''.join(row)) <= set('|:- ') + if is_table: + self.separator = row + + return is_table + + def run(self, parent: etree.Element, blocks: list[str]) -> None: + """ Parse a table block and build table. """ + block = blocks.pop(0).split('\n') + header = block[0].strip(' ') + rows = [] if len(block) < 3 else block[2:] + + # Get alignment of columns + align: list[str | None] = [] + for c in self.separator: + c = c.strip(' ') + if c.startswith(':') and c.endswith(':'): + align.append('center') + elif c.startswith(':'): + align.append('left') + elif c.endswith(':'): + align.append('right') + else: + align.append(None) + + # Build table + table = etree.SubElement(parent, 'table') + thead = etree.SubElement(table, 'thead') + self._build_row(header, thead, align) + tbody = etree.SubElement(table, 'tbody') + if len(rows) == 0: + # Handle empty table + self._build_empty_row(tbody, align) + else: + for row in rows: + self._build_row(row.strip(' '), tbody, align) + + def _build_empty_row(self, parent: etree.Element, align: Sequence[str | None]) -> None: + """Build an empty row.""" + tr = etree.SubElement(parent, 'tr') + count = len(align) + while count: + etree.SubElement(tr, 'td') + count -= 1 + + def _build_row(self, row: str, parent: etree.Element, align: Sequence[str | None]) -> None: + """ Given a row of text, build table cells. """ + tr = etree.SubElement(parent, 'tr') + tag = 'td' + if parent.tag == 'thead': + tag = 'th' + cells = self._split_row(row) + # We use align here rather than cells to ensure every row + # contains the same number of columns. + for i, a in enumerate(align): + c = etree.SubElement(tr, tag) + try: + c.text = cells[i].strip(' ') + except IndexError: # pragma: no cover + c.text = "" + if a: + if self.config['use_align_attribute']: + c.set('align', a) + else: + c.set('style', f'text-align: {a};') + + def _split_row(self, row: str) -> list[str]: + """ split a row of text into list of cells. """ + if self.border: + if row.startswith('|'): + row = row[1:] + row = self.RE_END_BORDER.sub('', row) + return self._split(row) + + def _split(self, row: str) -> list[str]: + """ split a row of text with some code into a list of cells. """ + elements = [] + pipes = [] + tics = [] + tic_points = [] + tic_region = [] + good_pipes = [] + + # Parse row + # Throw out \\, and \| + for m in self.RE_CODE_PIPES.finditer(row): + # Store ` data (len, start_pos, end_pos) + if m.group(2): + # \`+ + # Store length of each tic group: subtract \ + tics.append(len(m.group(2)) - 1) + # Store start of group, end of group, and escape length + tic_points.append((m.start(2), m.end(2) - 1, 1)) + elif m.group(3): + # `+ + # Store length of each tic group + tics.append(len(m.group(3))) + # Store start of group, end of group, and escape length + tic_points.append((m.start(3), m.end(3) - 1, 0)) + # Store pipe location + elif m.group(5): + pipes.append(m.start(5)) + + # Pair up tics according to size if possible + # Subtract the escape length *only* from the opening. + # Walk through tic list and see if tic has a close. + # Store the tic region (start of region, end of region). + pos = 0 + tic_len = len(tics) + while pos < tic_len: + try: + tic_size = tics[pos] - tic_points[pos][2] + if tic_size == 0: + raise ValueError + index = tics[pos + 1:].index(tic_size) + 1 + tic_region.append((tic_points[pos][0], tic_points[pos + index][1])) + pos += index + 1 + except ValueError: + pos += 1 + + # Resolve pipes. Check if they are within a tic pair region. + # Walk through pipes comparing them to each region. + # - If pipe position is less that a region, it isn't in a region + # - If it is within a region, we don't want it, so throw it out + # - If we didn't throw it out, it must be a table pipe + for pipe in pipes: + throw_out = False + for region in tic_region: + if pipe < region[0]: + # Pipe is not in a region + break + elif region[0] <= pipe <= region[1]: + # Pipe is within a code region. Throw it out. + throw_out = True + break + if not throw_out: + good_pipes.append(pipe) + + # Split row according to table delimiters. + pos = 0 + for pipe in good_pipes: + elements.append(row[pos:pipe]) + pos = pipe + 1 + elements.append(row[pos:]) + return elements + + +class TableExtension(Extension): + """ Add tables to Markdown. """ + + def __init__(self, **kwargs): + self.config = { + 'use_align_attribute': [False, 'True to use align attribute instead of style.'], + } + """ Default configuration options. """ + + super().__init__(**kwargs) + + def extendMarkdown(self, md): + """ Add an instance of `TableProcessor` to `BlockParser`. """ + if '|' not in md.ESCAPED_CHARS: + md.ESCAPED_CHARS.append('|') + processor = TableProcessor(md.parser, self.getConfigs()) + md.parser.blockprocessors.register(processor, 'table', 75) + + +def makeExtension(**kwargs): # pragma: no cover + return TableExtension(**kwargs) diff --git a/lib/python3.12/site-packages/markdown/extensions/toc.py b/lib/python3.12/site-packages/markdown/extensions/toc.py new file mode 100644 index 0000000000000000000000000000000000000000..c92f928b6660be3deb03e7bedfd243c6570ff1c5 --- /dev/null +++ b/lib/python3.12/site-packages/markdown/extensions/toc.py @@ -0,0 +1,488 @@ +# Table of Contents Extension for Python-Markdown +# =============================================== + +# See https://Python-Markdown.github.io/extensions/toc +# for documentation. + +# Original code Copyright 2008 [Jack Miller](https://codezen.org/) + +# All changes Copyright 2008-2024 The Python Markdown Project + +# License: [BSD](https://opensource.org/licenses/bsd-license.php) + +""" +Add table of contents support to Python-Markdown. + +See the [documentation](https://Python-Markdown.github.io/extensions/toc) +for details. +""" + +from __future__ import annotations + +from . import Extension +from ..treeprocessors import Treeprocessor +from ..util import parseBoolValue, AMP_SUBSTITUTE, deprecated, HTML_PLACEHOLDER_RE, AtomicString +from ..treeprocessors import UnescapeTreeprocessor +from ..serializers import RE_AMP +import re +import html +import unicodedata +from copy import deepcopy +import xml.etree.ElementTree as etree +from typing import TYPE_CHECKING, Any, Iterator, MutableSet + +if TYPE_CHECKING: # pragma: no cover + from markdown import Markdown + + +def slugify(value: str, separator: str, unicode: bool = False) -> str: + """ Slugify a string, to make it URL friendly. """ + if not unicode: + # Replace Extended Latin characters with ASCII, i.e. `žlutý` => `zluty` + value = unicodedata.normalize('NFKD', value) + value = value.encode('ascii', 'ignore').decode('ascii') + value = re.sub(r'[^\w\s-]', '', value).strip().lower() + return re.sub(r'[{}\s]+'.format(separator), separator, value) + + +def slugify_unicode(value: str, separator: str) -> str: + """ Slugify a string, to make it URL friendly while preserving Unicode characters. """ + return slugify(value, separator, unicode=True) + + +IDCOUNT_RE = re.compile(r'^(.*)_([0-9]+)$') + + +def unique(id: str, ids: MutableSet[str]) -> str: + """ Ensure id is unique in set of ids. Append '_1', '_2'... if not """ + while id in ids or not id: + m = IDCOUNT_RE.match(id) + if m: + id = '%s_%d' % (m.group(1), int(m.group(2))+1) + else: + id = '%s_%d' % (id, 1) + ids.add(id) + return id + + +@deprecated('Use `render_inner_html` and `striptags` instead.') +def get_name(el: etree.Element) -> str: + """Get title name.""" + + text = [] + for c in el.itertext(): + if isinstance(c, AtomicString): + text.append(html.unescape(c)) + else: + text.append(c) + return ''.join(text).strip() + + +@deprecated('Use `run_postprocessors`, `render_inner_html` and/or `striptags` instead.') +def stashedHTML2text(text: str, md: Markdown, strip_entities: bool = True) -> str: + """ Extract raw HTML from stash, reduce to plain text and swap with placeholder. """ + def _html_sub(m: re.Match[str]) -> str: + """ Substitute raw html with plain text. """ + try: + raw = md.htmlStash.rawHtmlBlocks[int(m.group(1))] + except (IndexError, TypeError): # pragma: no cover + return m.group(0) + # Strip out tags and/or entities - leaving text + res = re.sub(r'(<[^>]+>)', '', raw) + if strip_entities: + res = re.sub(r'(&[\#a-zA-Z0-9]+;)', '', res) + return res + + return HTML_PLACEHOLDER_RE.sub(_html_sub, text) + + +def unescape(text: str) -> str: + """ Unescape Markdown backslash escaped text. """ + c = UnescapeTreeprocessor() + return c.unescape(text) + + +def strip_tags(text: str) -> str: + """ Strip HTML tags and return plain text. Note: HTML entities are unaffected. """ + # A comment could contain a tag, so strip comments first + while (start := text.find('<!--')) != -1 and (end := text.find('-->', start)) != -1: + text = f'{text[:start]}{text[end + 3:]}' + + while (start := text.find('<')) != -1 and (end := text.find('>', start)) != -1: + text = f'{text[:start]}{text[end + 1:]}' + + # Collapse whitespace + text = ' '.join(text.split()) + return text + + +def escape_cdata(text: str) -> str: + """ Escape character data. """ + if "&" in text: + # Only replace & when not part of an entity + text = RE_AMP.sub('&', text) + if "<" in text: + text = text.replace("<", "<") + if ">" in text: + text = text.replace(">", ">") + return text + + +def run_postprocessors(text: str, md: Markdown) -> str: + """ Run postprocessors from Markdown instance on text. """ + for pp in md.postprocessors: + text = pp.run(text) + return text.strip() + + +def render_inner_html(el: etree.Element, md: Markdown) -> str: + """ Fully render inner html of an `etree` element as a string. """ + # The `UnescapeTreeprocessor` runs after `toc` extension so run here. + text = unescape(md.serializer(el)) + + # strip parent tag + start = text.index('>') + 1 + end = text.rindex('<') + text = text[start:end].strip() + + return run_postprocessors(text, md) + + +def remove_fnrefs(root: etree.Element) -> etree.Element: + """ Remove footnote references from a copy of the element, if any are present. """ + # Remove footnote references, which look like this: `<sup id="fnref:1">...</sup>`. + # If there are no `sup` elements, then nothing to do. + if next(root.iter('sup'), None) is None: + return root + root = deepcopy(root) + # Find parent elements that contain `sup` elements. + for parent in root.findall('.//sup/..'): + carry_text = "" + for child in reversed(parent): # Reversed for the ability to mutate during iteration. + # Remove matching footnote references but carry any `tail` text to preceding elements. + if child.tag == 'sup' and child.get('id', '').startswith('fnref'): + carry_text = f'{child.tail or ""}{carry_text}' + parent.remove(child) + elif carry_text: + child.tail = f'{child.tail or ""}{carry_text}' + carry_text = "" + if carry_text: + parent.text = f'{parent.text or ""}{carry_text}' + return root + + +def nest_toc_tokens(toc_list): + """Given an unsorted list with errors and skips, return a nested one. + + [{'level': 1}, {'level': 2}] + => + [{'level': 1, 'children': [{'level': 2, 'children': []}]}] + + A wrong list is also converted: + + [{'level': 2}, {'level': 1}] + => + [{'level': 2, 'children': []}, {'level': 1, 'children': []}] + """ + + ordered_list = [] + if len(toc_list): + # Initialize everything by processing the first entry + last = toc_list.pop(0) + last['children'] = [] + levels = [last['level']] + ordered_list.append(last) + parents = [] + + # Walk the rest nesting the entries properly + while toc_list: + t = toc_list.pop(0) + current_level = t['level'] + t['children'] = [] + + # Reduce depth if current level < last item's level + if current_level < levels[-1]: + # Pop last level since we know we are less than it + levels.pop() + + # Pop parents and levels we are less than or equal to + to_pop = 0 + for p in reversed(parents): + if current_level <= p['level']: + to_pop += 1 + else: # pragma: no cover + break + if to_pop: + levels = levels[:-to_pop] + parents = parents[:-to_pop] + + # Note current level as last + levels.append(current_level) + + # Level is the same, so append to + # the current parent (if available) + if current_level == levels[-1]: + (parents[-1]['children'] if parents + else ordered_list).append(t) + + # Current level is > last item's level, + # So make last item a parent and append current as child + else: + last['children'].append(t) + parents.append(last) + levels.append(current_level) + last = t + + return ordered_list + + +class TocTreeprocessor(Treeprocessor): + """ Step through document and build TOC. """ + + def __init__(self, md: Markdown, config: dict[str, Any]): + super().__init__(md) + + self.marker: str = config["marker"] + self.title: str = config["title"] + self.base_level = int(config["baselevel"]) - 1 + self.slugify = config["slugify"] + self.sep = config["separator"] + self.toc_class = config["toc_class"] + self.title_class: str = config["title_class"] + self.use_anchors: bool = parseBoolValue(config["anchorlink"]) + self.anchorlink_class: str = config["anchorlink_class"] + self.use_permalinks = parseBoolValue(config["permalink"], False) + if self.use_permalinks is None: + self.use_permalinks = config["permalink"] + self.permalink_class: str = config["permalink_class"] + self.permalink_title: str = config["permalink_title"] + self.permalink_leading: bool | None = parseBoolValue(config["permalink_leading"], False) + self.header_rgx = re.compile("[Hh][123456]") + if isinstance(config["toc_depth"], str) and '-' in config["toc_depth"]: + self.toc_top, self.toc_bottom = [int(x) for x in config["toc_depth"].split('-')] + else: + self.toc_top = 1 + self.toc_bottom = int(config["toc_depth"]) + + def iterparent(self, node: etree.Element) -> Iterator[tuple[etree.Element, etree.Element]]: + """ Iterator wrapper to get allowed parent and child all at once. """ + + # We do not allow the marker inside a header as that + # would causes an endless loop of placing a new TOC + # inside previously generated TOC. + for child in node: + if not self.header_rgx.match(child.tag) and child.tag not in ['pre', 'code']: + yield node, child + yield from self.iterparent(child) + + def replace_marker(self, root: etree.Element, elem: etree.Element) -> None: + """ Replace marker with elem. """ + for (p, c) in self.iterparent(root): + text = ''.join(c.itertext()).strip() + if not text: + continue + + # To keep the output from screwing up the + # validation by putting a `<div>` inside of a `<p>` + # we actually replace the `<p>` in its entirety. + + # The `<p>` element may contain more than a single text content + # (`nl2br` can introduce a `<br>`). In this situation, `c.text` returns + # the very first content, ignore children contents or tail content. + # `len(c) == 0` is here to ensure there is only text in the `<p>`. + if c.text and c.text.strip() == self.marker and len(c) == 0: + for i in range(len(p)): + if p[i] == c: + p[i] = elem + break + + def set_level(self, elem: etree.Element) -> None: + """ Adjust header level according to base level. """ + level = int(elem.tag[-1]) + self.base_level + if level > 6: + level = 6 + elem.tag = 'h%d' % level + + def add_anchor(self, c: etree.Element, elem_id: str) -> None: + anchor = etree.Element("a") + anchor.text = c.text + anchor.attrib["href"] = "#" + elem_id + anchor.attrib["class"] = self.anchorlink_class + c.text = "" + for elem in c: + anchor.append(elem) + while len(c): + c.remove(c[0]) + c.append(anchor) + + def add_permalink(self, c: etree.Element, elem_id: str) -> None: + permalink = etree.Element("a") + permalink.text = ("%spara;" % AMP_SUBSTITUTE + if self.use_permalinks is True + else self.use_permalinks) + permalink.attrib["href"] = "#" + elem_id + permalink.attrib["class"] = self.permalink_class + if self.permalink_title: + permalink.attrib["title"] = self.permalink_title + if self.permalink_leading: + permalink.tail = c.text + c.text = "" + c.insert(0, permalink) + else: + c.append(permalink) + + def build_toc_div(self, toc_list: list) -> etree.Element: + """ Return a string div given a toc list. """ + div = etree.Element("div") + div.attrib["class"] = self.toc_class + + # Add title to the div + if self.title: + header = etree.SubElement(div, "span") + if self.title_class: + header.attrib["class"] = self.title_class + header.text = self.title + + def build_etree_ul(toc_list: list, parent: etree.Element) -> etree.Element: + ul = etree.SubElement(parent, "ul") + for item in toc_list: + # List item link, to be inserted into the toc div + li = etree.SubElement(ul, "li") + link = etree.SubElement(li, "a") + link.text = item.get('name', '') + link.attrib["href"] = '#' + item.get('id', '') + if item['children']: + build_etree_ul(item['children'], li) + return ul + + build_etree_ul(toc_list, div) + + if 'prettify' in self.md.treeprocessors: + self.md.treeprocessors['prettify'].run(div) + + return div + + def run(self, doc: etree.Element) -> None: + # Get a list of id attributes + used_ids = set() + for el in doc.iter(): + if "id" in el.attrib: + used_ids.add(el.attrib["id"]) + + toc_tokens = [] + for el in doc.iter(): + if isinstance(el.tag, str) and self.header_rgx.match(el.tag): + self.set_level(el) + innerhtml = render_inner_html(remove_fnrefs(el), self.md) + name = strip_tags(innerhtml) + + # Do not override pre-existing ids + if "id" not in el.attrib: + el.attrib["id"] = unique(self.slugify(html.unescape(name), self.sep), used_ids) + + data_toc_label = '' + if 'data-toc-label' in el.attrib: + data_toc_label = run_postprocessors(unescape(el.attrib['data-toc-label']), self.md) + # Overwrite name with sanitized value of `data-toc-label`. + name = escape_cdata(strip_tags(data_toc_label)) + # Remove the data-toc-label attribute as it is no longer needed + del el.attrib['data-toc-label'] + + if int(el.tag[-1]) >= self.toc_top and int(el.tag[-1]) <= self.toc_bottom: + toc_tokens.append({ + 'level': int(el.tag[-1]), + 'id': unescape(el.attrib["id"]), + 'name': name, + 'html': innerhtml, + 'data-toc-label': data_toc_label + }) + + if self.use_anchors: + self.add_anchor(el, el.attrib["id"]) + if self.use_permalinks not in [False, None]: + self.add_permalink(el, el.attrib["id"]) + + toc_tokens = nest_toc_tokens(toc_tokens) + div = self.build_toc_div(toc_tokens) + if self.marker: + self.replace_marker(doc, div) + + # serialize and attach to markdown instance. + toc = self.md.serializer(div) + for pp in self.md.postprocessors: + toc = pp.run(toc) + self.md.toc_tokens = toc_tokens + self.md.toc = toc + + +class TocExtension(Extension): + + TreeProcessorClass = TocTreeprocessor + + def __init__(self, **kwargs): + self.config = { + 'marker': [ + '[TOC]', + 'Text to find and replace with Table of Contents. Set to an empty string to disable. ' + 'Default: `[TOC]`.' + ], + 'title': [ + '', 'Title to insert into TOC `<div>`. Default: an empty string.' + ], + 'title_class': [ + 'toctitle', 'CSS class used for the title. Default: `toctitle`.' + ], + 'toc_class': [ + 'toc', 'CSS class(es) used for the link. Default: `toclink`.' + ], + 'anchorlink': [ + False, 'True if header should be a self link. Default: `False`.' + ], + 'anchorlink_class': [ + 'toclink', 'CSS class(es) used for the link. Defaults: `toclink`.' + ], + 'permalink': [ + 0, 'True or link text if a Sphinx-style permalink should be added. Default: `False`.' + ], + 'permalink_class': [ + 'headerlink', 'CSS class(es) used for the link. Default: `headerlink`.' + ], + 'permalink_title': [ + 'Permanent link', 'Title attribute of the permalink. Default: `Permanent link`.' + ], + 'permalink_leading': [ + False, + 'True if permalinks should be placed at start of the header, rather than end. Default: False.' + ], + 'baselevel': ['1', 'Base level for headers. Default: `1`.'], + 'slugify': [ + slugify, 'Function to generate anchors based on header text. Default: `slugify`.' + ], + 'separator': ['-', 'Word separator. Default: `-`.'], + 'toc_depth': [ + 6, + 'Define the range of section levels to include in the Table of Contents. A single integer ' + '(b) defines the bottom section level (<h1>..<hb>) only. A string consisting of two digits ' + 'separated by a hyphen in between (`2-5`) defines the top (t) and the bottom (b) (<ht>..<hb>). ' + 'Default: `6` (bottom).' + ], + } + """ Default configuration options. """ + + super().__init__(**kwargs) + + def extendMarkdown(self, md): + """ Add TOC tree processor to Markdown. """ + md.registerExtension(self) + self.md = md + self.reset() + tocext = self.TreeProcessorClass(md, self.getConfigs()) + md.treeprocessors.register(tocext, 'toc', 5) + + def reset(self) -> None: + self.md.toc = '' + self.md.toc_tokens = [] + + +def makeExtension(**kwargs): # pragma: no cover + return TocExtension(**kwargs) diff --git a/lib/python3.12/site-packages/markdown/extensions/wikilinks.py b/lib/python3.12/site-packages/markdown/extensions/wikilinks.py new file mode 100644 index 0000000000000000000000000000000000000000..bb72c3d8fd9989c0452e64af0e1113995b1bd0fe --- /dev/null +++ b/lib/python3.12/site-packages/markdown/extensions/wikilinks.py @@ -0,0 +1,97 @@ +# WikiLinks Extension for Python-Markdown +# ====================================== + +# Converts [[WikiLinks]] to relative links. + +# See https://Python-Markdown.github.io/extensions/wikilinks +# for documentation. + +# Original code Copyright [Waylan Limberg](https://github.com/waylan). + +# All changes Copyright The Python Markdown Project + +# License: [BSD](https://opensource.org/licenses/bsd-license.php) + +""" +Converts `[[WikiLinks]]` to relative links. + +See the [documentation](https://Python-Markdown.github.io/extensions/wikilinks) +for details. +""" + +from __future__ import annotations + +from . import Extension +from ..inlinepatterns import InlineProcessor +import xml.etree.ElementTree as etree +import re +from typing import Any + + +def build_url(label: str, base: str, end: str) -> str: + """ Build a URL from the label, a base, and an end. """ + clean_label = re.sub(r'([ ]+_)|(_[ ]+)|([ ]+)', '_', label) + return '{}{}{}'.format(base, clean_label, end) + + +class WikiLinkExtension(Extension): + """ Add inline processor to Markdown. """ + + def __init__(self, **kwargs): + self.config = { + 'base_url': ['/', 'String to append to beginning or URL.'], + 'end_url': ['/', 'String to append to end of URL.'], + 'html_class': ['wikilink', 'CSS hook. Leave blank for none.'], + 'build_url': [build_url, 'Callable formats URL from label.'], + } + """ Default configuration options. """ + super().__init__(**kwargs) + + def extendMarkdown(self, md): + self.md = md + + # append to end of inline patterns + WIKILINK_RE = r'\[\[([\w0-9_ -]+)\]\]' + wikilinkPattern = WikiLinksInlineProcessor(WIKILINK_RE, self.getConfigs()) + wikilinkPattern.md = md + md.inlinePatterns.register(wikilinkPattern, 'wikilink', 75) + + +class WikiLinksInlineProcessor(InlineProcessor): + """ Build link from `wikilink`. """ + + def __init__(self, pattern: str, config: dict[str, Any]): + super().__init__(pattern) + self.config = config + + def handleMatch(self, m: re.Match[str], data: str) -> tuple[etree.Element | str, int, int]: + if m.group(1).strip(): + base_url, end_url, html_class = self._getMeta() + label = m.group(1).strip() + url = self.config['build_url'](label, base_url, end_url) + a = etree.Element('a') + a.text = label + a.set('href', url) + if html_class: + a.set('class', html_class) + else: + a = '' + return a, m.start(0), m.end(0) + + def _getMeta(self) -> tuple[str, str, str]: + """ Return meta data or `config` data. """ + base_url = self.config['base_url'] + end_url = self.config['end_url'] + html_class = self.config['html_class'] + if hasattr(self.md, 'Meta'): + if 'wiki_base_url' in self.md.Meta: + base_url = self.md.Meta['wiki_base_url'][0] + if 'wiki_end_url' in self.md.Meta: + end_url = self.md.Meta['wiki_end_url'][0] + if 'wiki_html_class' in self.md.Meta: + html_class = self.md.Meta['wiki_html_class'][0] + return base_url, end_url, html_class + + +def makeExtension(**kwargs): # pragma: no cover + return WikiLinkExtension(**kwargs) diff --git a/lib/python3.12/site-packages/markdown/htmlparser.py b/lib/python3.12/site-packages/markdown/htmlparser.py new file mode 100644 index 0000000000000000000000000000000000000000..74dbe25a8823c84ee78bf31d39338f5d0f2efaf9 --- /dev/null +++ b/lib/python3.12/site-packages/markdown/htmlparser.py @@ -0,0 +1,416 @@ +# Python Markdown + +# A Python implementation of John Gruber's Markdown. + +# Documentation: https://python-markdown.github.io/ +# GitHub: https://github.com/Python-Markdown/markdown/ +# PyPI: https://pypi.org/project/Markdown/ + +# Started by Manfred Stienstra (http://www.dwerg.net/). +# Maintained for a few years by Yuri Takhteyev (http://www.freewisdom.org). +# Currently maintained by Waylan Limberg (https://github.com/waylan), +# Dmitry Shachnev (https://github.com/mitya57) and Isaac Muse (https://github.com/facelessuser). + +# Copyright 2007-2023 The Python Markdown Project (v. 1.7 and later) +# Copyright 2004, 2005, 2006 Yuri Takhteyev (v. 0.2-1.6b) +# Copyright 2004 Manfred Stienstra (the original version) + +# License: BSD (see LICENSE.md for details). + +""" +This module imports a copy of [`html.parser.HTMLParser`][] and modifies it heavily through monkey-patches. +A copy is imported rather than the module being directly imported as this ensures that the user can import +and use the unmodified library for their own needs. +""" + +from __future__ import annotations + +import re +import importlib.util +import sys +from typing import TYPE_CHECKING, Sequence + +if TYPE_CHECKING: # pragma: no cover + from markdown import Markdown + +# Included for versions which do not have current comment fix +commentclose = re.compile(r'--!?>') +commentabruptclose = re.compile(r'-?>') + +# Import a copy of the html.parser lib as `htmlparser` so we can monkeypatch it. +# Users can still do `from html import parser` and get the default behavior. +spec = importlib.util.find_spec('html.parser') +htmlparser = importlib.util.module_from_spec(spec) +spec.loader.exec_module(htmlparser) +sys.modules['htmlparser'] = htmlparser + +# This is a hack. We are sneaking in `</>` so we can capture it without the HTML parser +# throwing it away. When we see it, we will process it as data. +htmlparser.starttagopen = re.compile('<[a-zA-Z]|</>') + +# Monkeypatch `HTMLParser` to only accept `?>` to close Processing Instructions. +htmlparser.piclose = re.compile(r'\?>') +# Monkeypatch `HTMLParser` to only recognize entity references with a closing semicolon. +htmlparser.entityref = re.compile(r'&([a-zA-Z][-.a-zA-Z0-9]*);') +# Monkeypatch `HTMLParser` to no longer support partial entities. We are always feeding a complete block, +# so the 'incomplete' functionality is unnecessary. As the `entityref` regex is run right before incomplete, +# and the two regex are the same, then incomplete will simply never match and we avoid the logic within. +htmlparser.incomplete = htmlparser.entityref +# Monkeypatch `HTMLParser` to not accept a backtick in a tag name, attribute name, or bare value. +htmlparser.locatestarttagend_tolerant = re.compile(r""" + <[a-zA-Z][^`\t\n\r\f />\x00]* # tag name <= added backtick here + (?:[\s/]* # optional whitespace before attribute name + (?:(?<=['"\s/])[^`\s/>][^\s/=>]* # attribute name <= added backtick here + (?:\s*=+\s* # value indicator + (?:'[^']*' # LITA-enclosed value + |"[^"]*" # LIT-enclosed value + |(?!['"])[^`>\s]* # bare value <= added backtick here + ) + (?:\s*,)* # possibly followed by a comma + )?(?:\s|/(?!>))* + )* + )? + \s* # trailing whitespace +""", re.VERBOSE) +htmlparser.locatetagend = re.compile(r""" + [a-zA-Z][^`\t\n\r\f />]* # tag name + [\t\n\r\f /]* # optional whitespace before attribute name + (?:(?<=['"\t\n\r\f /])[^`\t\n\r\f />][^\t\n\r\f /=>]* # attribute name + (?:= # value indicator + (?:'[^']*' # LITA-enclosed value + |"[^"]*" # LIT-enclosed value + |(?!['"])[^>\t\n\r\f ]* # bare value + ) + )? + [\t\n\r\f /]* # possibly followed by a space + )* + >? +""", re.VERBOSE) + +# Match a blank line at the start of a block of text (two newlines). +# The newlines may be preceded by additional whitespace. +blank_line_re = re.compile(r'^([ ]*\n){2}') + + +class HTMLExtractor(htmlparser.HTMLParser): + """ + Extract raw HTML from text. + + The raw HTML is stored in the [`htmlStash`][markdown.util.HtmlStash] of the + [`Markdown`][markdown.Markdown] instance passed to `md` and the remaining text + is stored in `cleandoc` as a list of strings. + """ + + def __init__(self, md: Markdown, *args, **kwargs): + if 'convert_charrefs' not in kwargs: + kwargs['convert_charrefs'] = False + + # Block tags that should contain no content (self closing) + self.empty_tags = set(['hr']) + + self.lineno_start_cache = [0] + + self.override_comment_update = False + self.override_comment_start = 0 + + # This calls self.reset + super().__init__(*args, **kwargs) + self.md = md + + def reset(self): + """Reset this instance. Loses all unprocessed data.""" + self.inraw = False + self.intail = False + self.stack: list[str] = [] # When `inraw==True`, stack contains a list of tags + self._cache: list[str] = [] + self.cleandoc: list[str] = [] + self.lineno_start_cache = [0] + self.override_comment_start = 0 + self.override_comment_update = False + + super().reset() + + def close(self): + """Handle any buffered data.""" + super().close() + if len(self.rawdata): + # Temp fix for https://bugs.python.org/issue41989 + # TODO: remove this when the bug is fixed in all supported Python versions. + if self.convert_charrefs and not self.cdata_elem: # pragma: no cover + self.handle_data(htmlparser.unescape(self.rawdata)) + else: + self.handle_data(self.rawdata) + # Handle any unclosed tags. + if len(self._cache): + self.cleandoc.append(self.md.htmlStash.store(''.join(self._cache))) + self._cache = [] + + @property + def line_offset(self) -> int: + """Returns char index in `self.rawdata` for the start of the current line. """ + for ii in range(len(self.lineno_start_cache)-1, self.lineno-1): + last_line_start_pos = self.lineno_start_cache[ii] + lf_pos = self.rawdata.find('\n', last_line_start_pos) + if lf_pos == -1: + # No more newlines found. Use end of raw data as start of line beyond end. + lf_pos = len(self.rawdata) + self.lineno_start_cache.append(lf_pos+1) + + return self.lineno_start_cache[self.lineno-1] + + def at_line_start(self) -> bool: + """ + Returns True if current position is at start of line. + + Allows for up to three blank spaces at start of line. + """ + if self.offset == 0: + return True + if self.offset > 3: + return False + # Confirm up to first 3 chars are whitespace + return self.rawdata[self.line_offset:self.line_offset + self.offset].strip() == '' + + def get_endtag_text(self, tag: str) -> str: + """ + Returns the text of the end tag. + + If it fails to extract the actual text from the raw data, it builds a closing tag with `tag`. + """ + # Attempt to extract actual tag from raw source text + start = self.line_offset + self.offset + m = htmlparser.endendtag.search(self.rawdata, start) + if m: + return self.rawdata[start:m.end()] + else: # pragma: no cover + # Failed to extract from raw data. Assume well formed and lowercase. + return '</{}>'.format(tag) + + def handle_starttag(self, tag: str, attrs: Sequence[tuple[str, str]]): + # Handle tags that should always be empty and do not specify a closing tag + if tag in self.empty_tags: + self.handle_startendtag(tag, attrs) + return + + if self.md.is_block_level(tag) and (self.intail or (self.at_line_start() and not self.inraw)): + # Started a new raw block. Prepare stack. + self.inraw = True + self.cleandoc.append('\n') + + text = self.get_starttag_text() + if self.inraw: + self.stack.append(tag) + self._cache.append(text) + else: + self.cleandoc.append(text) + if tag in self.CDATA_CONTENT_ELEMENTS: + # This is presumably a standalone tag in a code span (see #1036). + self.clear_cdata_mode() + + def handle_endtag(self, tag: str): + text = self.get_endtag_text(tag) + + if self.inraw: + self._cache.append(text) + if tag in self.stack: + # Remove tag from stack + while self.stack: + if self.stack.pop() == tag: + break + if len(self.stack) == 0: + # End of raw block. + if blank_line_re.match(self.rawdata[self.line_offset + self.offset + len(text):]): + # Preserve blank line and end of raw block. + self._cache.append('\n') + else: + # More content exists after `endtag`. + self.intail = True + # Reset stack. + self.inraw = False + self.cleandoc.append(self.md.htmlStash.store(''.join(self._cache))) + # Insert blank line between this and next line. + self.cleandoc.append('\n\n') + self._cache = [] + else: + self.cleandoc.append(text) + + def handle_data(self, data: str): + if self.intail and '\n' in data: + self.intail = False + if self.inraw: + self._cache.append(data) + else: + self.cleandoc.append(data) + + def handle_empty_tag(self, data: str, is_block: bool): + """ Handle empty tags (`<data>`). """ + if self.inraw or self.intail: + # Append this to the existing raw block + self._cache.append(data) + elif self.at_line_start() and is_block: + # Handle this as a standalone raw block + if blank_line_re.match(self.rawdata[self.line_offset + self.offset + len(data):]): + # Preserve blank line after tag in raw block. + data += '\n' + else: + # More content exists after tag. + self.intail = True + item = self.cleandoc[-1] if self.cleandoc else '' + # If we only have one newline before block element, add another + if not item.endswith('\n\n') and item.endswith('\n'): + self.cleandoc.append('\n') + self.cleandoc.append(self.md.htmlStash.store(data)) + # Insert blank line between this and next line. + self.cleandoc.append('\n\n') + else: + self.cleandoc.append(data) + + def handle_startendtag(self, tag: str, attrs): + self.handle_empty_tag(self.get_starttag_text(), is_block=self.md.is_block_level(tag)) + + def handle_charref(self, name: str): + self.handle_empty_tag('&#{};'.format(name), is_block=False) + + def handle_entityref(self, name: str): + self.handle_empty_tag('&{};'.format(name), is_block=False) + + def handle_comment(self, data: str): + # Check if the comment is unclosed, if so, we need to override position + j = len(self.rawdata) - len(data) + i = j - 2 + if self.rawdata[i:j] == '</': + self.handle_data('<') + self.override_comment_start = i + self.override_comment_update = True + return + self.handle_empty_tag('<!--{}-->'.format(data), is_block=True) + + def updatepos(self, i: int, j: int) -> int: + if self.override_comment_update: + self.override_comment_update = False + i = self.override_comment_start + j = self.override_comment_start + 1 + return super().updatepos(i, j) + + def handle_decl(self, data: str): + self.handle_empty_tag('<!{}>'.format(data), is_block=True) + + def handle_pi(self, data: str): + self.handle_empty_tag('<?{}?>'.format(data), is_block=True) + + def unknown_decl(self, data: str): + end = ']]>' if data.startswith('CDATA[') else ']>' + self.handle_empty_tag('<![{}{}'.format(data, end), is_block=True) + + def parse_pi(self, i: int) -> int: + if self.at_line_start() or self.intail: + return super().parse_pi(i) + # This is not the beginning of a raw block so treat as plain data + # and avoid consuming any tags which may follow (see #1066). + self.handle_data('<?') + return i + 2 + + # Internal -- parse comment, return length or -1 if not terminated + # see https://html.spec.whatwg.org/multipage/parsing.html#comment-start-state + def parse_comment(self, i, report=True): + rawdata = self.rawdata + assert rawdata.startswith('<!--', i), 'unexpected call to parse_comment()' + match = commentclose.search(rawdata, i+4) + if not match: + self.handle_data('<') + return i + 1 + if report: + j = match.start() + self.handle_comment(rawdata[i+4: j]) + return match.end() + + def parse_html_declaration(self, i: int) -> int: + if self.at_line_start() or self.intail: + if self.rawdata[i:i+3] == '<![' and not self.rawdata[i:i+9] == '<![CDATA[': + # We have encountered the bug in #1534 (Python bug `gh-77057`). + # Provide an override until we drop support for Python < 3.13. + result = self.parse_bogus_comment(i) + if result == -1: + self.handle_data(self.rawdata[i:i + 1]) + return i + 1 + return result + return super().parse_html_declaration(i) + # This is not the beginning of a raw block so treat as plain data + # and avoid consuming any tags which may follow (see #1066). + self.handle_data('<!') + return i + 2 + + def parse_bogus_comment(self, i: int, report: int = 0) -> int: + # Override the default behavior so that bogus comments get passed + # through unaltered by setting `report` to `0` (see #1425). + pos = super().parse_bogus_comment(i, report) + if pos == -1: # pragma: no cover + return -1 + self.handle_empty_tag(self.rawdata[i:pos], is_block=False) + return pos + + # The rest has been copied from base class in standard lib to address #1036. + # As `__startag_text` is private, all references to it must be in this subclass. + # The last few lines of `parse_starttag` are reversed so that `handle_starttag` + # can override `cdata_mode` in certain situations (in a code span). + __starttag_text: str | None = None + + def get_starttag_text(self) -> str: + """Return full source of start tag: `<...>`.""" + return self.__starttag_text + + def parse_starttag(self, i: int) -> int: # pragma: no cover + # Treat `</>` as normal data as it is not a real tag. + if self.rawdata[i:i + 3] == '</>': + self.handle_data(self.rawdata[i:i + 3]) + return i + 3 + + self.__starttag_text = None + endpos = self.check_for_whole_start_tag(i) + if endpos < 0: + self.handle_data(self.rawdata[i:i + 1]) + return i + 1 + rawdata = self.rawdata + self.__starttag_text = rawdata[i:endpos] + + # Now parse the data between `i+1` and `j` into a tag and `attrs` + attrs = [] + match = htmlparser.tagfind_tolerant.match(rawdata, i+1) + assert match, 'unexpected call to parse_starttag()' + k = match.end() + self.lasttag = tag = match.group(1).lower() + while k < endpos: + m = htmlparser.attrfind_tolerant.match(rawdata, k) + if not m: + break + attrname, rest, attrvalue = m.group(1, 2, 3) + if not rest: + attrvalue = None + elif attrvalue[:1] == '\'' == attrvalue[-1:] or \ + attrvalue[:1] == '"' == attrvalue[-1:]: # noqa: E127 + attrvalue = attrvalue[1:-1] + if attrvalue: + attrvalue = htmlparser.unescape(attrvalue) + attrs.append((attrname.lower(), attrvalue)) + k = m.end() + + end = rawdata[k:endpos].strip() + if end not in (">", "/>"): + lineno, offset = self.getpos() + if "\n" in self.__starttag_text: + lineno = lineno + self.__starttag_text.count("\n") + offset = len(self.__starttag_text) \ + - self.__starttag_text.rfind("\n") # noqa: E127 + else: + offset = offset + len(self.__starttag_text) + self.handle_data(rawdata[i:endpos]) + return endpos + if end.endswith('/>'): + # XHTML-style empty tag: `<span attr="value" />` + self.handle_startendtag(tag, attrs) + else: + # *** set `cdata_mode` first so we can override it in `handle_starttag` (see #1036) *** + if tag in self.CDATA_CONTENT_ELEMENTS: + self.set_cdata_mode(tag) + self.handle_starttag(tag, attrs) + return endpos diff --git a/lib/python3.12/site-packages/markdown/inlinepatterns.py b/lib/python3.12/site-packages/markdown/inlinepatterns.py new file mode 100644 index 0000000000000000000000000000000000000000..9f24512bfa828bb3b59b86edb1cf70bb1afba58e --- /dev/null +++ b/lib/python3.12/site-packages/markdown/inlinepatterns.py @@ -0,0 +1,996 @@ +# Python Markdown + +# A Python implementation of John Gruber's Markdown. + +# Documentation: https://python-markdown.github.io/ +# GitHub: https://github.com/Python-Markdown/markdown/ +# PyPI: https://pypi.org/project/Markdown/ + +# Started by Manfred Stienstra (http://www.dwerg.net/). +# Maintained for a few years by Yuri Takhteyev (http://www.freewisdom.org). +# Currently maintained by Waylan Limberg (https://github.com/waylan), +# Dmitry Shachnev (https://github.com/mitya57) and Isaac Muse (https://github.com/facelessuser). + +# Copyright 2007-2023 The Python Markdown Project (v. 1.7 and later) +# Copyright 2004, 2005, 2006 Yuri Takhteyev (v. 0.2-1.6b) +# Copyright 2004 Manfred Stienstra (the original version) + +# License: BSD (see LICENSE.md for details). + +""" +In version 3.0, a new, more flexible inline processor was added, [`markdown.inlinepatterns.InlineProcessor`][]. The +original inline patterns, which inherit from [`markdown.inlinepatterns.Pattern`][] or one of its children are still +supported, though users are encouraged to migrate. + +The new `InlineProcessor` provides two major enhancements to `Patterns`: + +1. Inline Processors no longer need to match the entire block, so regular expressions no longer need to start with + `r'^(.*?)'` and end with `r'(.*?)%'`. This runs faster. The returned [`Match`][re.Match] object will only contain + what is explicitly matched in the pattern, and extension pattern groups now start with `m.group(1)`. + +2. The `handleMatch` method now takes an additional input called `data`, which is the entire block under analysis, + not just what is matched with the specified pattern. The method now returns the element *and* the indexes relative + to `data` that the return element is replacing (usually `m.start(0)` and `m.end(0)`). If the boundaries are + returned as `None`, it is assumed that the match did not take place, and nothing will be altered in `data`. + + This allows handling of more complex constructs than regular expressions can handle, e.g., matching nested + brackets, and explicit control of the span "consumed" by the processor. + +""" + +from __future__ import annotations + +from . import util +from typing import TYPE_CHECKING, Any, Collection, NamedTuple +import re +import xml.etree.ElementTree as etree +from html import entities + +if TYPE_CHECKING: # pragma: no cover + from markdown import Markdown + + +def build_inlinepatterns(md: Markdown, **kwargs: Any) -> util.Registry[InlineProcessor]: + """ + Build the default set of inline patterns for Markdown. + + The order in which processors and/or patterns are applied is very important - e.g. if we first replace + `http://.../` links with `<a>` tags and _then_ try to replace inline HTML, we would end up with a mess. So, we + apply the expressions in the following order: + + * backticks and escaped characters have to be handled before everything else so that we can preempt any markdown + patterns by escaping them; + + * then we handle the various types of links (auto-links must be handled before inline HTML); + + * then we handle inline HTML. At this point we will simply replace all inline HTML strings with a placeholder + and add the actual HTML to a stash; + + * finally we apply strong, emphasis, etc. + + """ + inlinePatterns = util.Registry() + inlinePatterns.register(BacktickInlineProcessor(BACKTICK_RE), 'backtick', 190) + inlinePatterns.register(EscapeInlineProcessor(ESCAPE_RE, md), 'escape', 180) + inlinePatterns.register(ReferenceInlineProcessor(REFERENCE_RE, md), 'reference', 170) + inlinePatterns.register(LinkInlineProcessor(LINK_RE, md), 'link', 160) + inlinePatterns.register(ImageInlineProcessor(IMAGE_LINK_RE, md), 'image_link', 150) + inlinePatterns.register( + ImageReferenceInlineProcessor(IMAGE_REFERENCE_RE, md), 'image_reference', 140 + ) + inlinePatterns.register( + ShortReferenceInlineProcessor(REFERENCE_RE, md), 'short_reference', 130 + ) + inlinePatterns.register( + ShortImageReferenceInlineProcessor(IMAGE_REFERENCE_RE, md), 'short_image_ref', 125 + ) + inlinePatterns.register(AutolinkInlineProcessor(AUTOLINK_RE, md), 'autolink', 120) + inlinePatterns.register(AutomailInlineProcessor(AUTOMAIL_RE, md), 'automail', 110) + inlinePatterns.register(SubstituteTagInlineProcessor(LINE_BREAK_RE, 'br'), 'linebreak', 100) + inlinePatterns.register(HtmlInlineProcessor(HTML_RE, md), 'html', 90) + inlinePatterns.register(HtmlInlineProcessor(ENTITY_RE, md), 'entity', 80) + inlinePatterns.register(SimpleTextInlineProcessor(NOT_STRONG_RE), 'not_strong', 70) + inlinePatterns.register(AsteriskProcessor(r'\*'), 'em_strong', 60) + inlinePatterns.register(UnderscoreProcessor(r'_'), 'em_strong2', 50) + return inlinePatterns + + +# The actual regular expressions for patterns +# ----------------------------------------------------------------------------- + +NOIMG = r'(?<!\!)' +""" Match not an image. Partial regular expression which matches if not preceded by `!`. """ + +BACKTICK_RE = r'(?:(?<!\\)((?:\\{2})+)(?=`+)|(?<!\\)(`+)(.+?)(?<!`)\2(?!`))' +""" Match backtick quoted string (`` `e=f()` `` or ``` ``e=f("`")`` ```). """ + +ESCAPE_RE = r'\\(.)' +""" Match a backslash escaped character (`\\<` or `\\*`). """ + +EMPHASIS_RE = r'(\*)([^\*]+)\1' +""" Match emphasis with an asterisk (`*emphasis*`). """ + +STRONG_RE = r'(\*{2})(.+?)\1' +""" Match strong with an asterisk (`**strong**`). """ + +SMART_STRONG_RE = r'(?<!\w)(_{2})(?!_)(.+?)(?<!_)\1(?!\w)' +""" Match strong with underscore while ignoring middle word underscores (`__smart__strong__`). """ + +SMART_EMPHASIS_RE = r'(?<!\w)(_)(?!_)(.+?)(?<!_)\1(?!\w)' +""" Match emphasis with underscore while ignoring middle word underscores (`_smart_emphasis_`). """ + +SMART_STRONG_EM_RE = r'(?<!\w)(\_)\1(?!\1)(.+?)(?<!\w)\1(?!\1)(.+?)\1{3}(?!\w)' +""" Match strong emphasis with underscores (`__strong _em__`). """ + +EM_STRONG_RE = r'(\*)\1{2}(.+?)\1(.*?)\1{2}' +""" Match emphasis strong with asterisk (`***strongem***` or `***em*strong**`). """ + +EM_STRONG2_RE = r'(_)\1{2}(.+?)\1(.*?)\1{2}' +""" Match emphasis strong with underscores (`___emstrong___` or `___em_strong__`). """ + +STRONG_EM_RE = r'(\*)\1{2}(.+?)\1{2}(.*?)\1' +""" Match strong emphasis with asterisk (`***strong**em*`). """ + +STRONG_EM2_RE = r'(_)\1{2}(.+?)\1{2}(.*?)\1' +""" Match strong emphasis with underscores (`___strong__em_`). """ + +STRONG_EM3_RE = r'(\*)\1(?!\1)([^*]+?)\1(?!\1)(.+?)\1{3}' +""" Match strong emphasis with asterisk (`**strong*em***`). """ + +LINK_RE = NOIMG + r'\[' +""" Match start of in-line link (`[text](url)` or `[text](<url>)` or `[text](url "title")`). """ + +IMAGE_LINK_RE = r'\!\[' +""" Match start of in-line image link (`![alttxt](url)` or `![alttxt](<url>)`). """ + +REFERENCE_RE = LINK_RE +""" Match start of reference link (`[Label][3]`). """ + +IMAGE_REFERENCE_RE = IMAGE_LINK_RE +""" Match start of image reference (`![alt text][2]`). """ + +NOT_STRONG_RE = r'((^|(?<=\s))(\*{1,3}|_{1,3})(?=\s|$))' +""" Match a stand-alone `*` or `_`. """ + +AUTOLINK_RE = r'<((?:[Ff]|[Hh][Tt])[Tt][Pp][Ss]?://[^<>]*)>' +""" Match an automatic link (`<http://www.example.com>`). """ + +AUTOMAIL_RE = r'<([^<> !]+@[^@<> ]+)>' +""" Match an automatic email link (`<me@example.com>`). """ + +HTML_RE = ( + r'(<(\/?[a-zA-Z][^<>@ ]*( [^<>]*)?|' # Tag + r'!--(?:(?!<!--|-->).)*--|' # Comment + r'[?](?:(?!<[?]|[?]>).)*[?]|' # Processing instruction + r'!\[CDATA\[(?:(?!<!\[CDATA\[|\]\]>).)*\]\]' # `CDATA` + ')>)' +) +""" Match an HTML tag (`<...>`). """ + +ENTITY_RE = r'(&(?:\#[0-9]+|\#x[0-9a-fA-F]+|[a-zA-Z0-9]+);)' +""" Match an HTML entity (`&` (decimal) or `&` (hex) or `&` (named)). """ + +LINE_BREAK_RE = r' \n' +""" Match two spaces at end of line. """ + + +def dequote(string: str) -> str: + """Remove quotes from around a string.""" + if ((string.startswith('"') and string.endswith('"')) or + (string.startswith("'") and string.endswith("'"))): + return string[1:-1] + else: + return string + + +class EmStrongItem(NamedTuple): + """Emphasis/strong pattern item.""" + pattern: re.Pattern[str] + builder: str + tags: str + + +# The pattern classes +# ----------------------------------------------------------------------------- + + +class Pattern: # pragma: no cover + """ + Base class that inline patterns subclass. + + Inline patterns are handled by means of `Pattern` subclasses, one per regular expression. + Each pattern object uses a single regular expression and must support the following methods: + [`getCompiledRegExp`][markdown.inlinepatterns.Pattern.getCompiledRegExp] and + [`handleMatch`][markdown.inlinepatterns.Pattern.handleMatch]. + + All the regular expressions used by `Pattern` subclasses must capture the whole block. For this + reason, they all start with `^(.*)` and end with `(.*)!`. When passing a regular expression on + class initialization, the `^(.*)` and `(.*)!` are added automatically and the regular expression + is pre-compiled. + + It is strongly suggested that the newer style [`markdown.inlinepatterns.InlineProcessor`][] that + use a more efficient and flexible search approach be used instead. However, the older style + `Pattern` remains for backward compatibility with many existing third-party extensions. + + """ + + ANCESTOR_EXCLUDES: Collection[str] = tuple() + """ + A collection of elements which are undesirable ancestors. The processor will be skipped if it + would cause the content to be a descendant of one of the listed tag names. + """ + + compiled_re: re.Pattern[str] + md: Markdown | None + + def __init__(self, pattern: str, md: Markdown | None = None): + """ + Create an instant of an inline pattern. + + Arguments: + pattern: A regular expression that matches a pattern. + md: An optional pointer to the instance of `markdown.Markdown` and is available as + `self.md` on the class instance. + + + """ + self.pattern = pattern + self.compiled_re = re.compile(r"^(.*?)%s(.*)$" % pattern, + re.DOTALL | re.UNICODE) + + self.md = md + + def getCompiledRegExp(self) -> re.Pattern: + """ Return a compiled regular expression. """ + return self.compiled_re + + def handleMatch(self, m: re.Match[str]) -> etree.Element | str: + """Return a ElementTree element from the given match. + + Subclasses should override this method. + + Arguments: + m: A match object containing a match of the pattern. + + Returns: An ElementTree Element object. + + """ + pass # pragma: no cover + + def type(self) -> str: + """ Return class name, to define pattern type """ + return self.__class__.__name__ + + def unescape(self, text: str) -> str: + """ Return unescaped text given text with an inline placeholder. """ + try: + stash = self.md.treeprocessors['inline'].stashed_nodes + except KeyError: # pragma: no cover + return text + + def get_stash(m): + id = m.group(1) + if id in stash: + value = stash.get(id) + if isinstance(value, str): + return value + else: + # An `etree` Element - return text content only + return ''.join(value.itertext()) + return util.INLINE_PLACEHOLDER_RE.sub(get_stash, text) + + +class InlineProcessor(Pattern): + """ + Base class that inline processors subclass. + + This is the newer style inline processor that uses a more + efficient and flexible search approach. + + """ + + def __init__(self, pattern: str, md: Markdown | None = None): + """ + Create an instant of an inline processor. + + Arguments: + pattern: A regular expression that matches a pattern. + md: An optional pointer to the instance of `markdown.Markdown` and is available as + `self.md` on the class instance. + + """ + self.pattern = pattern + self.compiled_re = re.compile(pattern, re.DOTALL | re.UNICODE) + + # API for Markdown to pass `safe_mode` into instance + self.safe_mode = False + self.md = md + + def handleMatch(self, m: re.Match[str], data: str) -> tuple[etree.Element | str | None, int | None, int | None]: + """Return a ElementTree element from the given match and the + start and end index of the matched text. + + If `start` and/or `end` are returned as `None`, it will be + assumed that the processor did not find a valid region of text. + + Subclasses should override this method. + + Arguments: + m: A re match object containing a match of the pattern. + data: The buffer currently under analysis. + + Returns: + el: The ElementTree element, text or None. + start: The start of the region that has been matched or None. + end: The end of the region that has been matched or None. + + """ + pass # pragma: no cover + + +class SimpleTextPattern(Pattern): # pragma: no cover + """ Return a simple text of `group(2)` of a Pattern. """ + def handleMatch(self, m: re.Match[str]) -> str: + """ Return string content of `group(2)` of a matching pattern. """ + return m.group(2) + + +class SimpleTextInlineProcessor(InlineProcessor): + """ Return a simple text of `group(1)` of a Pattern. """ + def handleMatch(self, m: re.Match[str], data: str) -> tuple[str, int, int]: + """ Return string content of `group(1)` of a matching pattern. """ + return m.group(1), m.start(0), m.end(0) + + +class EscapeInlineProcessor(InlineProcessor): + """ Return an escaped character. """ + + def handleMatch(self, m: re.Match[str], data: str) -> tuple[str | None, int, int]: + """ + If the character matched by `group(1)` of a pattern is in [`ESCAPED_CHARS`][markdown.Markdown.ESCAPED_CHARS] + then return the integer representing the character's Unicode code point (as returned by [`ord`][]) wrapped + in [`util.STX`][markdown.util.STX] and [`util.ETX`][markdown.util.ETX]. + + If the matched character is not in [`ESCAPED_CHARS`][markdown.Markdown.ESCAPED_CHARS], then return `None`. + """ + + char = m.group(1) + if char in self.md.ESCAPED_CHARS: + return '{}{}{}'.format(util.STX, ord(char), util.ETX), m.start(0), m.end(0) + else: + return None, m.start(0), m.end(0) + + +class SimpleTagPattern(Pattern): # pragma: no cover + """ + Return element of type `tag` with a text attribute of `group(3)` + of a Pattern. + + """ + def __init__(self, pattern: str, tag: str): + """ + Create an instant of an simple tag pattern. + + Arguments: + pattern: A regular expression that matches a pattern. + tag: Tag of element. + + """ + Pattern.__init__(self, pattern) + self.tag = tag + """ The tag of the rendered element. """ + + def handleMatch(self, m: re.Match[str]) -> etree.Element: + """ + Return [`Element`][xml.etree.ElementTree.Element] of type `tag` with the string in `group(3)` of a + matching pattern as the Element's text. + """ + el = etree.Element(self.tag) + el.text = m.group(3) + return el + + +class SimpleTagInlineProcessor(InlineProcessor): + """ + Return element of type `tag` with a text attribute of `group(2)` + of a Pattern. + + """ + def __init__(self, pattern: str, tag: str): + """ + Create an instant of an simple tag processor. + + Arguments: + pattern: A regular expression that matches a pattern. + tag: Tag of element. + + """ + InlineProcessor.__init__(self, pattern) + self.tag = tag + """ The tag of the rendered element. """ + + def handleMatch(self, m: re.Match[str], data: str) -> tuple[etree.Element, int, int]: # pragma: no cover + """ + Return [`Element`][xml.etree.ElementTree.Element] of type `tag` with the string in `group(2)` of a + matching pattern as the Element's text. + """ + el = etree.Element(self.tag) + el.text = m.group(2) + return el, m.start(0), m.end(0) + + +class SubstituteTagPattern(SimpleTagPattern): # pragma: no cover + """ Return an element of type `tag` with no children. """ + def handleMatch(self, m: re.Match[str]) -> etree.Element: + """ Return empty [`Element`][xml.etree.ElementTree.Element] of type `tag`. """ + return etree.Element(self.tag) + + +class SubstituteTagInlineProcessor(SimpleTagInlineProcessor): + """ Return an element of type `tag` with no children. """ + def handleMatch(self, m: re.Match[str], data: str) -> tuple[etree.Element, int, int]: + """ Return empty [`Element`][xml.etree.ElementTree.Element] of type `tag`. """ + return etree.Element(self.tag), m.start(0), m.end(0) + + +class BacktickInlineProcessor(InlineProcessor): + """ Return a `<code>` element containing the escaped matching text. """ + def __init__(self, pattern: str): + InlineProcessor.__init__(self, pattern) + self.ESCAPED_BSLASH = '{}{}{}'.format(util.STX, ord('\\'), util.ETX) + self.tag = 'code' + """ The tag of the rendered element. """ + + def handleMatch(self, m: re.Match[str], data: str) -> tuple[etree.Element | str, int, int]: + """ + If the match contains `group(3)` of a pattern, then return a `code` + [`Element`][xml.etree.ElementTree.Element] which contains HTML escaped text (with + [`code_escape`][markdown.util.code_escape]) as an [`AtomicString`][markdown.util.AtomicString]. + + If the match does not contain `group(3)` then return the text of `group(1)` backslash escaped. + + """ + if m.group(3): + el = etree.Element(self.tag) + el.text = util.AtomicString(util.code_escape(m.group(3).strip())) + return el, m.start(0), m.end(0) + else: + return m.group(1).replace('\\\\', self.ESCAPED_BSLASH), m.start(0), m.end(0) + + +class DoubleTagPattern(SimpleTagPattern): # pragma: no cover + """Return a ElementTree element nested in tag2 nested in tag1. + + Useful for strong emphasis etc. + + """ + def handleMatch(self, m: re.Match[str]) -> etree.Element: + """ + Return [`Element`][xml.etree.ElementTree.Element] in following format: + `<tag1><tag2>group(3)</tag2>group(4)</tag2>` where `group(4)` is optional. + + """ + tag1, tag2 = self.tag.split(",") + el1 = etree.Element(tag1) + el2 = etree.SubElement(el1, tag2) + el2.text = m.group(3) + if len(m.groups()) == 5: + el2.tail = m.group(4) + return el1 + + +class DoubleTagInlineProcessor(SimpleTagInlineProcessor): + """Return a ElementTree element nested in tag2 nested in tag1. + + Useful for strong emphasis etc. + + """ + def handleMatch(self, m: re.Match[str], data: str) -> tuple[etree.Element, int, int]: # pragma: no cover + """ + Return [`Element`][xml.etree.ElementTree.Element] in following format: + `<tag1><tag2>group(2)</tag2>group(3)</tag2>` where `group(3)` is optional. + + """ + tag1, tag2 = self.tag.split(",") + el1 = etree.Element(tag1) + el2 = etree.SubElement(el1, tag2) + el2.text = m.group(2) + if len(m.groups()) == 3: + el2.tail = m.group(3) + return el1, m.start(0), m.end(0) + + +class HtmlInlineProcessor(InlineProcessor): + """ Store raw inline html and return a placeholder. """ + def handleMatch(self, m: re.Match[str], data: str) -> tuple[str, int, int]: + """ Store the text of `group(1)` of a pattern and return a placeholder string. """ + rawhtml = self.backslash_unescape(self.unescape(m.group(1))) + place_holder = self.md.htmlStash.store(rawhtml) + return place_holder, m.start(0), m.end(0) + + def unescape(self, text: str) -> str: + """ Return unescaped text given text with an inline placeholder. """ + try: + stash = self.md.treeprocessors['inline'].stashed_nodes + except KeyError: # pragma: no cover + return text + + def get_stash(m: re.Match[str]) -> str: + id = m.group(1) + value = stash.get(id) + if value is not None: + try: + # Ensure we don't have a placeholder inside a placeholder + return self.unescape(self.md.serializer(value)) + except Exception: + return r'\%s' % value + + return util.INLINE_PLACEHOLDER_RE.sub(get_stash, text) + + def backslash_unescape(self, text: str) -> str: + """ Return text with backslash escapes undone (backslashes are restored). """ + try: + RE = self.md.treeprocessors['unescape'].RE + except KeyError: # pragma: no cover + return text + + def _unescape(m: re.Match[str]) -> str: + return chr(int(m.group(1))) + + return RE.sub(_unescape, text) + + +class AsteriskProcessor(InlineProcessor): + """Emphasis processor for handling strong and em matches inside asterisks.""" + + PATTERNS = [ + EmStrongItem(re.compile(EM_STRONG_RE, re.DOTALL | re.UNICODE), 'double', 'strong,em'), + EmStrongItem(re.compile(STRONG_EM_RE, re.DOTALL | re.UNICODE), 'double', 'em,strong'), + EmStrongItem(re.compile(STRONG_EM3_RE, re.DOTALL | re.UNICODE), 'double2', 'strong,em'), + EmStrongItem(re.compile(STRONG_RE, re.DOTALL | re.UNICODE), 'single', 'strong'), + EmStrongItem(re.compile(EMPHASIS_RE, re.DOTALL | re.UNICODE), 'single', 'em') + ] + """ The various strong and emphasis patterns handled by this processor. """ + + def build_single(self, m: re.Match[str], tag: str, idx: int) -> etree.Element: + """Return single tag.""" + el1 = etree.Element(tag) + text = m.group(2) + self.parse_sub_patterns(text, el1, None, idx) + return el1 + + def build_double(self, m: re.Match[str], tags: str, idx: int) -> etree.Element: + """Return double tag.""" + + tag1, tag2 = tags.split(",") + el1 = etree.Element(tag1) + el2 = etree.Element(tag2) + text = m.group(2) + self.parse_sub_patterns(text, el2, None, idx) + el1.append(el2) + if len(m.groups()) == 3: + text = m.group(3) + self.parse_sub_patterns(text, el1, el2, idx) + return el1 + + def build_double2(self, m: re.Match[str], tags: str, idx: int) -> etree.Element: + """Return double tags (variant 2): `<strong>text <em>text</em></strong>`.""" + + tag1, tag2 = tags.split(",") + el1 = etree.Element(tag1) + el2 = etree.Element(tag2) + text = m.group(2) + self.parse_sub_patterns(text, el1, None, idx) + text = m.group(3) + el1.append(el2) + self.parse_sub_patterns(text, el2, None, idx) + return el1 + + def parse_sub_patterns( + self, data: str, parent: etree.Element, last: etree.Element | None, idx: int + ) -> None: + """ + Parses sub patterns. + + `data`: text to evaluate. + + `parent`: Parent to attach text and sub elements to. + + `last`: Last appended child to parent. Can also be None if parent has no children. + + `idx`: Current pattern index that was used to evaluate the parent. + """ + + offset = 0 + pos = 0 + + length = len(data) + while pos < length: + # Find the start of potential emphasis or strong tokens + if self.compiled_re.match(data, pos): + matched = False + # See if the we can match an emphasis/strong pattern + for index, item in enumerate(self.PATTERNS): + # Only evaluate patterns that are after what was used on the parent + if index <= idx: + continue + m = item.pattern.match(data, pos) + if m: + # Append child nodes to parent + # Text nodes should be appended to the last + # child if present, and if not, it should + # be added as the parent's text node. + text = data[offset:m.start(0)] + if text: + if last is not None: + last.tail = text + else: + parent.text = text + el = self.build_element(m, item.builder, item.tags, index) + parent.append(el) + last = el + # Move our position past the matched hunk + offset = pos = m.end(0) + matched = True + if not matched: + # We matched nothing, move on to the next character + pos += 1 + else: + # Increment position as no potential emphasis start was found. + pos += 1 + + # Append any leftover text as a text node. + text = data[offset:] + if text: + if last is not None: + last.tail = text + else: + parent.text = text + + def build_element(self, m: re.Match[str], builder: str, tags: str, index: int) -> etree.Element: + """Element builder.""" + + if builder == 'double2': + return self.build_double2(m, tags, index) + elif builder == 'double': + return self.build_double(m, tags, index) + else: + return self.build_single(m, tags, index) + + def handleMatch(self, m: re.Match[str], data: str) -> tuple[etree.Element | None, int | None, int | None]: + """Parse patterns.""" + + el = None + start = None + end = None + + for index, item in enumerate(self.PATTERNS): + m1 = item.pattern.match(data, m.start(0)) + if m1: + start = m1.start(0) + end = m1.end(0) + el = self.build_element(m1, item.builder, item.tags, index) + break + return el, start, end + + +class UnderscoreProcessor(AsteriskProcessor): + """Emphasis processor for handling strong and em matches inside underscores.""" + + PATTERNS = [ + EmStrongItem(re.compile(EM_STRONG2_RE, re.DOTALL | re.UNICODE), 'double', 'strong,em'), + EmStrongItem(re.compile(STRONG_EM2_RE, re.DOTALL | re.UNICODE), 'double', 'em,strong'), + EmStrongItem(re.compile(SMART_STRONG_EM_RE, re.DOTALL | re.UNICODE), 'double2', 'strong,em'), + EmStrongItem(re.compile(SMART_STRONG_RE, re.DOTALL | re.UNICODE), 'single', 'strong'), + EmStrongItem(re.compile(SMART_EMPHASIS_RE, re.DOTALL | re.UNICODE), 'single', 'em') + ] + """ The various strong and emphasis patterns handled by this processor. """ + + +class LinkInlineProcessor(InlineProcessor): + """ Return a link element from the given match. """ + RE_LINK = re.compile(r'''\(\s*(?:(<[^<>]*>)\s*(?:('[^']*'|"[^"]*")\s*)?\))?''', re.DOTALL | re.UNICODE) + RE_TITLE_CLEAN = re.compile(r'\s') + + def handleMatch(self, m: re.Match[str], data: str) -> tuple[etree.Element | None, int | None, int | None]: + """ Return an `a` [`Element`][xml.etree.ElementTree.Element] or `(None, None, None)`. """ + text, index, handled = self.getText(data, m.end(0)) + + if not handled: + return None, None, None + + href, title, index, handled = self.getLink(data, index) + if not handled: + return None, None, None + + el = etree.Element("a") + el.text = text + + el.set("href", href) + + if title is not None: + el.set("title", title) + + return el, m.start(0), index + + def getLink(self, data: str, index: int) -> tuple[str, str | None, int, bool]: + """Parse data between `()` of `[Text]()` allowing recursive `()`. """ + + href = '' + title: str | None = None + handled = False + + m = self.RE_LINK.match(data, pos=index) + if m and m.group(1): + # Matches [Text](<link> "title") + href = m.group(1)[1:-1].strip() + if m.group(2): + title = m.group(2)[1:-1] + index = m.end(0) + handled = True + elif m: + # Track bracket nesting and index in string + bracket_count = 1 + backtrack_count = 1 + start_index = m.end() + index = start_index + last_bracket = -1 + + # Primary (first found) quote tracking. + quote: str | None = None + start_quote = -1 + exit_quote = -1 + ignore_matches = False + + # Secondary (second found) quote tracking. + alt_quote = None + start_alt_quote = -1 + exit_alt_quote = -1 + + # Track last character + last = '' + + for pos in range(index, len(data)): + c = data[pos] + if c == '(': + # Count nested ( + # Don't increment the bracket count if we are sure we're in a title. + if not ignore_matches: + bracket_count += 1 + elif backtrack_count > 0: + backtrack_count -= 1 + elif c == ')': + # Match nested ) to ( + # Don't decrement if we are sure we are in a title that is unclosed. + if ((exit_quote != -1 and quote == last) or (exit_alt_quote != -1 and alt_quote == last)): + bracket_count = 0 + elif not ignore_matches: + bracket_count -= 1 + elif backtrack_count > 0: + backtrack_count -= 1 + # We've found our backup end location if the title doesn't resolve. + if backtrack_count == 0: + last_bracket = index + 1 + + elif c in ("'", '"'): + # Quote has started + if not quote: + # We'll assume we are now in a title. + # Brackets are quoted, so no need to match them (except for the final one). + ignore_matches = True + backtrack_count = bracket_count + bracket_count = 1 + start_quote = index + 1 + quote = c + # Secondary quote (in case the first doesn't resolve): [text](link'"title") + elif c != quote and not alt_quote: + start_alt_quote = index + 1 + alt_quote = c + # Update primary quote match + elif c == quote: + exit_quote = index + 1 + # Update secondary quote match + elif alt_quote and c == alt_quote: + exit_alt_quote = index + 1 + + index += 1 + + # Link is closed, so let's break out of the loop + if bracket_count == 0: + # Get the title if we closed a title string right before link closed + if exit_quote >= 0 and quote == last: + href = data[start_index:start_quote - 1] + title = ''.join(data[start_quote:exit_quote - 1]) + elif exit_alt_quote >= 0 and alt_quote == last: + href = data[start_index:start_alt_quote - 1] + title = ''.join(data[start_alt_quote:exit_alt_quote - 1]) + else: + href = data[start_index:index - 1] + break + + if c != ' ': + last = c + + # We have a scenario: `[test](link"notitle)` + # When we enter a string, we stop tracking bracket resolution in the main counter, + # but we do keep a backup counter up until we discover where we might resolve all brackets + # if the title string fails to resolve. + if bracket_count != 0 and backtrack_count == 0: + href = data[start_index:last_bracket - 1] + index = last_bracket + bracket_count = 0 + + handled = bracket_count == 0 + + if title is not None: + title = self.RE_TITLE_CLEAN.sub(' ', dequote(self.unescape(title.strip()))) + + href = self.unescape(href).strip() + + return href, title, index, handled + + def getText(self, data: str, index: int) -> tuple[str, int, bool]: + """Parse the content between `[]` of the start of an image or link + resolving nested square brackets. + + """ + bracket_count = 1 + text = [] + for pos in range(index, len(data)): + c = data[pos] + if c == ']': + bracket_count -= 1 + elif c == '[': + bracket_count += 1 + index += 1 + if bracket_count == 0: + break + text.append(c) + return ''.join(text), index, bracket_count == 0 + + +class ImageInlineProcessor(LinkInlineProcessor): + """ Return a `img` element from the given match. """ + + def handleMatch(self, m: re.Match[str], data: str) -> tuple[etree.Element | None, int | None, int | None]: + """ Return an `img` [`Element`][xml.etree.ElementTree.Element] or `(None, None, None)`. """ + text, index, handled = self.getText(data, m.end(0)) + if not handled: + return None, None, None + + src, title, index, handled = self.getLink(data, index) + if not handled: + return None, None, None + + el = etree.Element("img") + + el.set("src", src) + + if title is not None: + el.set("title", title) + + el.set('alt', self.unescape(text)) + return el, m.start(0), index + + +class ReferenceInlineProcessor(LinkInlineProcessor): + """ Match to a stored reference and return link element. """ + NEWLINE_CLEANUP_RE = re.compile(r'\s+', re.MULTILINE) + + RE_LINK = re.compile(r'\s?\[([^\]]*)\]', re.DOTALL | re.UNICODE) + + def handleMatch(self, m: re.Match[str], data: str) -> tuple[etree.Element | None, int | None, int | None]: + """ + Return [`Element`][xml.etree.ElementTree.Element] returned by `makeTag` method or `(None, None, None)`. + + """ + text, index, handled = self.getText(data, m.end(0)) + if not handled: + return None, None, None + + id, end, handled = self.evalId(data, index, text) + if not handled: + return None, None, None + + # Clean up line breaks in id + id = self.NEWLINE_CLEANUP_RE.sub(' ', id) + if id not in self.md.references: # ignore undefined refs + return None, m.start(0), end + + href, title = self.md.references[id] + + return self.makeTag(href, title, text), m.start(0), end + + def evalId(self, data: str, index: int, text: str) -> tuple[str | None, int, bool]: + """ + Evaluate the id portion of `[ref][id]`. + + If `[ref][]` use `[ref]`. + """ + m = self.RE_LINK.match(data, pos=index) + if not m: + return None, index, False + else: + id = m.group(1).lower() + end = m.end(0) + if not id: + id = text.lower() + return id, end, True + + def makeTag(self, href: str, title: str, text: str) -> etree.Element: + """ Return an `a` [`Element`][xml.etree.ElementTree.Element]. """ + el = etree.Element('a') + + el.set('href', href) + if title: + el.set('title', title) + + el.text = text + return el + + +class ShortReferenceInlineProcessor(ReferenceInlineProcessor): + """Short form of reference: `[google]`. """ + def evalId(self, data: str, index: int, text: str) -> tuple[str, int, bool]: + """Evaluate the id of `[ref]`. """ + + return text.lower(), index, True + + +class ImageReferenceInlineProcessor(ReferenceInlineProcessor): + """ Match to a stored reference and return `img` element. """ + def makeTag(self, href: str, title: str, text: str) -> etree.Element: + """ Return an `img` [`Element`][xml.etree.ElementTree.Element]. """ + el = etree.Element("img") + el.set("src", href) + if title: + el.set("title", title) + el.set("alt", self.unescape(text)) + return el + + +class ShortImageReferenceInlineProcessor(ImageReferenceInlineProcessor): + """ Short form of image reference: `![ref]`. """ + def evalId(self, data: str, index: int, text: str) -> tuple[str, int, bool]: + """Evaluate the id of `[ref]`. """ + + return text.lower(), index, True + + +class AutolinkInlineProcessor(InlineProcessor): + """ Return a link Element given an auto-link (`<http://example/com>`). """ + def handleMatch(self, m: re.Match[str], data: str) -> tuple[etree.Element, int, int]: + """ Return an `a` [`Element`][xml.etree.ElementTree.Element] of `group(1)`. """ + el = etree.Element("a") + el.set('href', self.unescape(m.group(1))) + el.text = util.AtomicString(m.group(1)) + return el, m.start(0), m.end(0) + + +class AutomailInlineProcessor(InlineProcessor): + """ + Return a `mailto` link Element given an auto-mail link (`<foo@example.com>`). + """ + def handleMatch(self, m: re.Match[str], data: str) -> tuple[etree.Element, int, int]: + """ Return an [`Element`][xml.etree.ElementTree.Element] containing a `mailto` link of `group(1)`. """ + el = etree.Element('a') + email = self.unescape(m.group(1)) + if email.startswith("mailto:"): + email = email[len("mailto:"):] + + def codepoint2name(code: int) -> str: + """Return entity definition by code, or the code if not defined.""" + entity = entities.codepoint2name.get(code) + if entity: + return "{}{};".format(util.AMP_SUBSTITUTE, entity) + else: + return "%s#%d;" % (util.AMP_SUBSTITUTE, code) + + letters = [codepoint2name(ord(letter)) for letter in email] + el.text = util.AtomicString(''.join(letters)) + + mailto = "mailto:" + email + mailto = "".join([util.AMP_SUBSTITUTE + '#%d;' % + ord(letter) for letter in mailto]) + el.set('href', mailto) + return el, m.start(0), m.end(0) diff --git a/lib/python3.12/site-packages/markdown/postprocessors.py b/lib/python3.12/site-packages/markdown/postprocessors.py new file mode 100644 index 0000000000000000000000000000000000000000..d4b0e1fdc7603649b2eeb2d8c0c188a2843f23a9 --- /dev/null +++ b/lib/python3.12/site-packages/markdown/postprocessors.py @@ -0,0 +1,131 @@ +# Python Markdown + +# A Python implementation of John Gruber's Markdown. + +# Documentation: https://python-markdown.github.io/ +# GitHub: https://github.com/Python-Markdown/markdown/ +# PyPI: https://pypi.org/project/Markdown/ + +# Started by Manfred Stienstra (http://www.dwerg.net/). +# Maintained for a few years by Yuri Takhteyev (http://www.freewisdom.org). +# Currently maintained by Waylan Limberg (https://github.com/waylan), +# Dmitry Shachnev (https://github.com/mitya57) and Isaac Muse (https://github.com/facelessuser). + +# Copyright 2007-2023 The Python Markdown Project (v. 1.7 and later) +# Copyright 2004, 2005, 2006 Yuri Takhteyev (v. 0.2-1.6b) +# Copyright 2004 Manfred Stienstra (the original version) + +# License: BSD (see LICENSE.md for details). + +""" + +Post-processors run on the text of the entire document after is has been serialized into a string. +Postprocessors should be used to work with the text just before output. Usually, they are used add +back sections that were extracted in a preprocessor, fix up outgoing encodings, or wrap the whole +document. + +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any +from . import util +import re + +if TYPE_CHECKING: # pragma: no cover + from markdown import Markdown + + +def build_postprocessors(md: Markdown, **kwargs: Any) -> util.Registry[Postprocessor]: + """ Build the default postprocessors for Markdown. """ + postprocessors = util.Registry() + postprocessors.register(RawHtmlPostprocessor(md), 'raw_html', 30) + postprocessors.register(AndSubstitutePostprocessor(), 'amp_substitute', 20) + return postprocessors + + +class Postprocessor(util.Processor): + """ + Postprocessors are run after the ElementTree it converted back into text. + + Each Postprocessor implements a `run` method that takes a pointer to a + text string, modifies it as necessary and returns a text string. + + Postprocessors must extend `Postprocessor`. + + """ + + def run(self, text: str) -> str: + """ + Subclasses of `Postprocessor` should implement a `run` method, which + takes the html document as a single text string and returns a + (possibly modified) string. + + """ + pass # pragma: no cover + + +class RawHtmlPostprocessor(Postprocessor): + """ Restore raw html to the document. """ + + BLOCK_LEVEL_REGEX = re.compile(r'^\<\/?([^ >]+)') + + def run(self, text: str) -> str: + """ Iterate over html stash and restore html. """ + def substitute_match(m: re.Match[str]) -> str: + if key := m.group(1): + wrapped = True + else: + key = m.group(2) + wrapped = False + if (key := int(key)) >= self.md.htmlStash.html_counter: + return m.group(0) + html = self.stash_to_string(self.md.htmlStash.rawHtmlBlocks[key]) + if not wrapped or self.isblocklevel(html): + return pattern.sub(substitute_match, html) + return pattern.sub(substitute_match, f"<p>{html}</p>") + + if self.md.htmlStash.html_counter: + base_placeholder = util.HTML_PLACEHOLDER % r'([0-9]+)' + pattern = re.compile(f'<p>{ base_placeholder }</p>|{ base_placeholder }') + return pattern.sub(substitute_match, text) + else: + return text + + def isblocklevel(self, html: str) -> bool: + """ Check is block of HTML is block-level. """ + m = self.BLOCK_LEVEL_REGEX.match(html) + if m: + if m.group(1)[0] in ('!', '?', '@', '%'): + # Comment, PHP etc... + return True + return self.md.is_block_level(m.group(1)) + return False + + def stash_to_string(self, text: str) -> str: + """ Convert a stashed object to a string. """ + return str(text) + + +class AndSubstitutePostprocessor(Postprocessor): + """ Restore valid entities """ + + def run(self, text: str) -> str: + text = text.replace(util.AMP_SUBSTITUTE, "&") + return text + + +@util.deprecated( + "This class is deprecated and will be removed in the future; " + "use [`UnescapeTreeprocessor`][markdown.treeprocessors.UnescapeTreeprocessor] instead." +) +class UnescapePostprocessor(Postprocessor): + """ Restore escaped chars. """ + + RE = re.compile(r'{}(\d+){}'.format(util.STX, util.ETX)) + + def unescape(self, m: re.Match[str]) -> str: + return chr(int(m.group(1))) + + def run(self, text: str) -> str: + return self.RE.sub(self.unescape, text) diff --git a/lib/python3.12/site-packages/markdown/preprocessors.py b/lib/python3.12/site-packages/markdown/preprocessors.py new file mode 100644 index 0000000000000000000000000000000000000000..0f63cdd36ab2f06222a18380bfe7ab5e47c3bfcf --- /dev/null +++ b/lib/python3.12/site-packages/markdown/preprocessors.py @@ -0,0 +1,91 @@ +# Python Markdown + +# A Python implementation of John Gruber's Markdown. + +# Documentation: https://python-markdown.github.io/ +# GitHub: https://github.com/Python-Markdown/markdown/ +# PyPI: https://pypi.org/project/Markdown/ + +# Started by Manfred Stienstra (http://www.dwerg.net/). +# Maintained for a few years by Yuri Takhteyev (http://www.freewisdom.org). +# Currently maintained by Waylan Limberg (https://github.com/waylan), +# Dmitry Shachnev (https://github.com/mitya57) and Isaac Muse (https://github.com/facelessuser). + +# Copyright 2007-2023 The Python Markdown Project (v. 1.7 and later) +# Copyright 2004, 2005, 2006 Yuri Takhteyev (v. 0.2-1.6b) +# Copyright 2004 Manfred Stienstra (the original version) + +# License: BSD (see LICENSE.md for details). + +""" +Preprocessors work on source text before it is broken down into its individual parts. +This is an excellent place to clean up bad characters or to extract portions for later +processing that the parser may otherwise choke on. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any +from . import util +from .htmlparser import HTMLExtractor +import re + +if TYPE_CHECKING: # pragma: no cover + from markdown import Markdown + + +def build_preprocessors(md: Markdown, **kwargs: Any) -> util.Registry[Preprocessor]: + """ Build and return the default set of preprocessors used by Markdown. """ + preprocessors = util.Registry() + preprocessors.register(NormalizeWhitespace(md), 'normalize_whitespace', 30) + preprocessors.register(HtmlBlockPreprocessor(md), 'html_block', 20) + return preprocessors + + +class Preprocessor(util.Processor): + """ + Preprocessors are run after the text is broken into lines. + + Each preprocessor implements a `run` method that takes a pointer to a + list of lines of the document, modifies it as necessary and returns + either the same pointer or a pointer to a new list. + + Preprocessors must extend `Preprocessor`. + + """ + def run(self, lines: list[str]) -> list[str]: + """ + Each subclass of `Preprocessor` should override the `run` method, which + takes the document as a list of strings split by newlines and returns + the (possibly modified) list of lines. + + """ + pass # pragma: no cover + + +class NormalizeWhitespace(Preprocessor): + """ Normalize whitespace for consistent parsing. """ + + def run(self, lines: list[str]) -> list[str]: + source = '\n'.join(lines) + source = source.replace(util.STX, "").replace(util.ETX, "") + source = source.replace("\r\n", "\n").replace("\r", "\n") + "\n\n" + source = source.expandtabs(self.md.tab_length) + source = re.sub(r'(?<=\n) +\n', '\n', source) + return source.split('\n') + + +class HtmlBlockPreprocessor(Preprocessor): + """ + Remove html blocks from the text and store them for later retrieval. + + The raw HTML is stored in the [`htmlStash`][markdown.util.HtmlStash] of the + [`Markdown`][markdown.Markdown] instance. + """ + + def run(self, lines: list[str]) -> list[str]: + source = '\n'.join(lines) + parser = HTMLExtractor(self.md) + parser.feed(source) + parser.close() + return ''.join(parser.cleandoc).split('\n') diff --git a/lib/python3.12/site-packages/markdown/serializers.py b/lib/python3.12/site-packages/markdown/serializers.py new file mode 100644 index 0000000000000000000000000000000000000000..573b2648349de11618af078d2f6c4207200a0732 --- /dev/null +++ b/lib/python3.12/site-packages/markdown/serializers.py @@ -0,0 +1,194 @@ +# Add x/html serialization to `Elementree` +# Taken from ElementTree 1.3 preview with slight modifications +# +# Copyright (c) 1999-2007 by Fredrik Lundh. All rights reserved. +# +# fredrik@pythonware.com +# https://www.pythonware.com/ +# +# -------------------------------------------------------------------- +# The ElementTree toolkit is +# +# Copyright (c) 1999-2007 by Fredrik Lundh +# +# By obtaining, using, and/or copying this software and/or its +# associated documentation, you agree that you have read, understood, +# and will comply with the following terms and conditions: +# +# Permission to use, copy, modify, and distribute this software and +# its associated documentation for any purpose and without fee is +# hereby granted, provided that the above copyright notice appears in +# all copies, and that both that copyright notice and this permission +# notice appear in supporting documentation, and that the name of +# Secret Labs AB or the author not be used in advertising or publicity +# pertaining to distribution of the software without specific, written +# prior permission. +# +# SECRET LABS AB AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD +# TO THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANT- +# ABILITY AND FITNESS. IN NO EVENT SHALL SECRET LABS AB OR THE AUTHOR +# BE LIABLE FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY +# DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +# WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS +# ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE +# OF THIS SOFTWARE. +# -------------------------------------------------------------------- + +""" +Python-Markdown provides two serializers which render [`ElementTree.Element`][xml.etree.ElementTree.Element] +objects to a string of HTML. Both functions wrap the same underlying code with only a few minor +differences as outlined below: + +1. Empty (self-closing) tags are rendered as `<tag>` for HTML and as `<tag />` for XHTML. +2. Boolean attributes are rendered as `attrname` for HTML and as `attrname="attrname"` for XHTML. +""" + +from __future__ import annotations + +from xml.etree.ElementTree import ProcessingInstruction +from xml.etree.ElementTree import Comment, ElementTree, Element, QName, HTML_EMPTY +import re +from typing import Callable, Literal, NoReturn + +__all__ = ['to_html_string', 'to_xhtml_string'] + +RE_AMP = re.compile(r'&(?!(?:\#[0-9]+|\#x[0-9a-f]+|[0-9a-z]+);)', re.I) + + +def _raise_serialization_error(text: str) -> NoReturn: # pragma: no cover + raise TypeError( + "cannot serialize {!r} (type {})".format(text, type(text).__name__) + ) + + +def _escape_cdata(text) -> str: + # escape character data + try: + # it's worth avoiding do-nothing calls for strings that are + # shorter than 500 character, or so. assume that's, by far, + # the most common case in most applications. + if "&" in text: + # Only replace & when not part of an entity + text = RE_AMP.sub('&', text) + if "<" in text: + text = text.replace("<", "<") + if ">" in text: + text = text.replace(">", ">") + return text + except (TypeError, AttributeError): # pragma: no cover + _raise_serialization_error(text) + + +def _escape_attrib(text: str) -> str: + # escape attribute value + try: + if "&" in text: + # Only replace & when not part of an entity + text = RE_AMP.sub('&', text) + if "<" in text: + text = text.replace("<", "<") + if ">" in text: + text = text.replace(">", ">") + if "\"" in text: + text = text.replace("\"", """) + if "\n" in text: + text = text.replace("\n", " ") + return text + except (TypeError, AttributeError): # pragma: no cover + _raise_serialization_error(text) + + +def _escape_attrib_html(text: str) -> str: + # escape attribute value + try: + if "&" in text: + # Only replace & when not part of an entity + text = RE_AMP.sub('&', text) + if "<" in text: + text = text.replace("<", "<") + if ">" in text: + text = text.replace(">", ">") + if "\"" in text: + text = text.replace("\"", """) + return text + except (TypeError, AttributeError): # pragma: no cover + _raise_serialization_error(text) + + +def _serialize_html(write: Callable[[str], None], elem: Element, format: Literal["html", "xhtml"]) -> None: + tag = elem.tag + text = elem.text + if tag is Comment: + write("<!--%s-->" % _escape_cdata(text)) + elif tag is ProcessingInstruction: + write("<?%s?>" % _escape_cdata(text)) + elif tag is None: + if text: + write(_escape_cdata(text)) + for e in elem: + _serialize_html(write, e, format) + else: + namespace_uri = None + if isinstance(tag, QName): + # `QNAME` objects store their data as a string: `{uri}tag` + if tag.text[:1] == "{": + namespace_uri, tag = tag.text[1:].split("}", 1) + else: + raise ValueError('QName objects must define a tag.') + write("<" + tag) + items = elem.items() + if items: + items = sorted(items) # lexical order + for k, v in items: + if isinstance(k, QName): + # Assume a text only `QName` + k = k.text + if isinstance(v, QName): + # Assume a text only `QName` + v = v.text + else: + v = _escape_attrib_html(v) + if k == v and format == 'html': + # handle boolean attributes + write(" %s" % v) + else: + write(' {}="{}"'.format(k, v)) + if namespace_uri: + write(' xmlns="%s"' % (_escape_attrib(namespace_uri))) + if format == "xhtml" and tag.lower() in HTML_EMPTY: + write(" />") + else: + write(">") + if text: + if tag.lower() in ["script", "style"]: + write(text) + else: + write(_escape_cdata(text)) + for e in elem: + _serialize_html(write, e, format) + if tag.lower() not in HTML_EMPTY: + write("</" + tag + ">") + if elem.tail: + write(_escape_cdata(elem.tail)) + + +def _write_html(root: Element, format: Literal["html", "xhtml"] = "html") -> str: + assert root is not None + data: list[str] = [] + write = data.append + _serialize_html(write, root, format) + return "".join(data) + + +# -------------------------------------------------------------------- +# public functions + + +def to_html_string(element: Element) -> str: + """ Serialize element and its children to a string of HTML5. """ + return _write_html(ElementTree(element).getroot(), format="html") + + +def to_xhtml_string(element: Element) -> str: + """ Serialize element and its children to a string of XHTML. """ + return _write_html(ElementTree(element).getroot(), format="xhtml") diff --git a/lib/python3.12/site-packages/markdown/test_tools.py b/lib/python3.12/site-packages/markdown/test_tools.py new file mode 100644 index 0000000000000000000000000000000000000000..895e44ec5290435791faf3fcf72741cfb21e440e --- /dev/null +++ b/lib/python3.12/site-packages/markdown/test_tools.py @@ -0,0 +1,224 @@ +# Python Markdown + +# A Python implementation of John Gruber's Markdown. + +# Documentation: https://python-markdown.github.io/ +# GitHub: https://github.com/Python-Markdown/markdown/ +# PyPI: https://pypi.org/project/Markdown/ + +# Started by Manfred Stienstra (http://www.dwerg.net/). +# Maintained for a few years by Yuri Takhteyev (http://www.freewisdom.org). +# Currently maintained by Waylan Limberg (https://github.com/waylan), +# Dmitry Shachnev (https://github.com/mitya57) and Isaac Muse (https://github.com/facelessuser). + +# Copyright 2007-2023 The Python Markdown Project (v. 1.7 and later) +# Copyright 2004, 2005, 2006 Yuri Takhteyev (v. 0.2-1.6b) +# Copyright 2004 Manfred Stienstra (the original version) + +# License: BSD (see LICENSE.md for details). + +""" A collection of tools for testing the Markdown code base and extensions. """ + +from __future__ import annotations + +import os +import sys +import unittest +import textwrap +from typing import Any +from . import markdown, Markdown, util + +try: + import tidylib +except ImportError: + tidylib = None + +__all__ = ['TestCase', 'LegacyTestCase', 'Kwargs'] + + +class TestCase(unittest.TestCase): + """ + A [`unittest.TestCase`][] subclass with helpers for testing Markdown output. + + Define `default_kwargs` as a `dict` of keywords to pass to Markdown for each + test. The defaults can be overridden on individual tests. + + The `assertMarkdownRenders` method accepts the source text, the expected + output, and any keywords to pass to Markdown. The `default_kwargs` are used + except where overridden by `kwargs`. The output and expected output are passed + to `TestCase.assertMultiLineEqual`. An `AssertionError` is raised with a diff + if the actual output does not equal the expected output. + + The `dedent` method is available to dedent triple-quoted strings if + necessary. + + In all other respects, behaves as `unittest.TestCase`. + """ + + default_kwargs: dict[str, Any] = {} + """ Default options to pass to Markdown for each test. """ + + def assertMarkdownRenders(self, source, expected, expected_attrs=None, **kwargs): + """ + Test that source Markdown text renders to expected output with given keywords. + + `expected_attrs` accepts a `dict`. Each key should be the name of an attribute + on the `Markdown` instance and the value should be the expected value after + the source text is parsed by Markdown. After the expected output is tested, + the expected value for each attribute is compared against the actual + attribute of the `Markdown` instance using `TestCase.assertEqual`. + """ + + expected_attrs = expected_attrs or {} + kws = self.default_kwargs.copy() + kws.update(kwargs) + md = Markdown(**kws) + output = md.convert(source) + self.assertMultiLineEqual(output, expected) + for key, value in expected_attrs.items(): + self.assertEqual(getattr(md, key), value) + + def dedent(self, text): + """ + Dedent text. + """ + + # TODO: If/when actual output ends with a newline, then use: + # return textwrap.dedent(text.strip('/n')) + return textwrap.dedent(text).strip() + + +class recursionlimit: + """ + A context manager which temporarily modifies the Python recursion limit. + + The testing framework, coverage, etc. may add an arbitrary number of levels to the depth. To maintain consistency + in the tests, the current stack depth is determined when called, then added to the provided limit. + + Example usage: + + ``` python + with recursionlimit(20): + # test code here + ``` + + See <https://stackoverflow.com/a/50120316/866026>. + """ + + def __init__(self, limit): + self.limit = util._get_stack_depth() + limit + self.old_limit = sys.getrecursionlimit() + + def __enter__(self): + sys.setrecursionlimit(self.limit) + + def __exit__(self, type, value, tb): + sys.setrecursionlimit(self.old_limit) + + +######################### +# Legacy Test Framework # +######################### + + +class Kwargs(dict): + """ A `dict` like class for holding keyword arguments. """ + pass + + +def _normalize_whitespace(text): + """ Normalize whitespace for a string of HTML using `tidylib`. """ + output, errors = tidylib.tidy_fragment(text, options={ + 'drop_empty_paras': 0, + 'fix_backslash': 0, + 'fix_bad_comments': 0, + 'fix_uri': 0, + 'join_styles': 0, + 'lower_literals': 0, + 'merge_divs': 0, + 'output_xhtml': 1, + 'quote_ampersand': 0, + 'newline': 'LF' + }) + return output + + +class LegacyTestMeta(type): + def __new__(cls, name, bases, dct): + + def generate_test(infile, outfile, normalize, kwargs): + def test(self): + with open(infile, encoding="utf-8") as f: + input = f.read() + with open(outfile, encoding="utf-8") as f: + # Normalize line endings + # (on Windows, git may have altered line endings). + expected = f.read().replace("\r\n", "\n") + output = markdown(input, **kwargs) + if tidylib and normalize: + try: + expected = _normalize_whitespace(expected) + output = _normalize_whitespace(output) + except OSError: + self.skipTest("Tidylib's c library not available.") + elif normalize: + self.skipTest('Tidylib not available.') + self.assertMultiLineEqual(output, expected) + return test + + location = dct.get('location', '') + exclude = dct.get('exclude', []) + normalize = dct.get('normalize', False) + input_ext = dct.get('input_ext', '.txt') + output_ext = dct.get('output_ext', '.html') + kwargs = dct.get('default_kwargs', Kwargs()) + + if os.path.isdir(location): + for file in os.listdir(location): + infile = os.path.join(location, file) + if os.path.isfile(infile): + tname, ext = os.path.splitext(file) + if ext == input_ext: + outfile = os.path.join(location, tname + output_ext) + tname = tname.replace(' ', '_').replace('-', '_') + kws = kwargs.copy() + if tname in dct: + kws.update(dct[tname]) + test_name = 'test_%s' % tname + if tname not in exclude: + dct[test_name] = generate_test(infile, outfile, normalize, kws) + else: + dct[test_name] = unittest.skip('Excluded')(lambda: None) + + return type.__new__(cls, name, bases, dct) + + +class LegacyTestCase(unittest.TestCase, metaclass=LegacyTestMeta): + """ + A [`unittest.TestCase`][] subclass for running Markdown's legacy file-based tests. + + A subclass should define various properties which point to a directory of + text-based test files and define various behaviors/defaults for those tests. + The following properties are supported: + + Attributes: + location (str): A path to the directory of test files. An absolute path is preferred. + exclude (list[str]): A list of tests to exclude. Each test name should comprise the filename + without an extension. + normalize (bool): A boolean value indicating if the HTML should be normalized. Default: `False`. + input_ext (str): A string containing the file extension of input files. Default: `.txt`. + output_ext (str): A string containing the file extension of expected output files. Default: `html`. + default_kwargs (Kwargs[str, Any]): The default set of keyword arguments for all test files in the directory. + + In addition, properties can be defined for each individual set of test files within + the directory. The property should be given the name of the file without the file + extension. Any spaces and dashes in the filename should be replaced with + underscores. The value of the property should be a `Kwargs` instance which + contains the keyword arguments that should be passed to `Markdown` for that + test file. The keyword arguments will "update" the `default_kwargs`. + + When the class instance is created, it will walk the given directory and create + a separate `Unitttest` for each set of test files using the naming scheme: + `test_filename`. One `Unittest` will be run for each set of input and output files. + """ + pass diff --git a/lib/python3.12/site-packages/markdown/treeprocessors.py b/lib/python3.12/site-packages/markdown/treeprocessors.py new file mode 100644 index 0000000000000000000000000000000000000000..9a27446d47d9bc3b386596bc16b93ac9de8c9911 --- /dev/null +++ b/lib/python3.12/site-packages/markdown/treeprocessors.py @@ -0,0 +1,476 @@ +# Python Markdown + +# A Python implementation of John Gruber's Markdown. + +# Documentation: https://python-markdown.github.io/ +# GitHub: https://github.com/Python-Markdown/markdown/ +# PyPI: https://pypi.org/project/Markdown/ + +# Started by Manfred Stienstra (http://www.dwerg.net/). +# Maintained for a few years by Yuri Takhteyev (http://www.freewisdom.org). +# Currently maintained by Waylan Limberg (https://github.com/waylan), +# Dmitry Shachnev (https://github.com/mitya57) and Isaac Muse (https://github.com/facelessuser). + +# Copyright 2007-2023 The Python Markdown Project (v. 1.7 and later) +# Copyright 2004, 2005, 2006 Yuri Takhteyev (v. 0.2-1.6b) +# Copyright 2004 Manfred Stienstra (the original version) + +# License: BSD (see LICENSE.md for details). + +""" +Tree processors manipulate the tree created by block processors. They can even create an entirely +new `ElementTree` object. This is an excellent place for creating summaries, adding collected +references, or last minute adjustments. + +""" + +from __future__ import annotations + +import re +import xml.etree.ElementTree as etree +from typing import TYPE_CHECKING, Any +from . import util +from . import inlinepatterns + +if TYPE_CHECKING: # pragma: no cover + from markdown import Markdown + + +def build_treeprocessors(md: Markdown, **kwargs: Any) -> util.Registry[Treeprocessor]: + """ Build the default `treeprocessors` for Markdown. """ + treeprocessors = util.Registry() + treeprocessors.register(InlineProcessor(md), 'inline', 20) + treeprocessors.register(PrettifyTreeprocessor(md), 'prettify', 10) + treeprocessors.register(UnescapeTreeprocessor(md), 'unescape', 0) + return treeprocessors + + +def isString(s: object) -> bool: + """ Return `True` if object is a string but not an [`AtomicString`][markdown.util.AtomicString]. """ + if not isinstance(s, util.AtomicString): + return isinstance(s, str) + return False + + +class Treeprocessor(util.Processor): + """ + `Treeprocessor`s are run on the `ElementTree` object before serialization. + + Each `Treeprocessor` implements a `run` method that takes a pointer to an + `Element` and modifies it as necessary. + + `Treeprocessors` must extend `markdown.Treeprocessor`. + + """ + def run(self, root: etree.Element) -> etree.Element | None: + """ + Subclasses of `Treeprocessor` should implement a `run` method, which + takes a root `Element`. This method can return another `Element` + object, and the existing root `Element` will be replaced, or it can + modify the current tree and return `None`. + """ + pass # pragma: no cover + + +class InlineProcessor(Treeprocessor): + """ + A `Treeprocessor` that traverses a tree, applying inline patterns. + """ + + def __init__(self, md: Markdown): + self.__placeholder_prefix = util.INLINE_PLACEHOLDER_PREFIX + self.__placeholder_suffix = util.ETX + self.__placeholder_length = 4 + len(self.__placeholder_prefix) \ + + len(self.__placeholder_suffix) + self.__placeholder_re = util.INLINE_PLACEHOLDER_RE + self.md = md + self.inlinePatterns = md.inlinePatterns + self.ancestors: list[str] = [] + + def __makePlaceholder(self, type: str) -> tuple[str, str]: + """ Generate a placeholder """ + id = "%04d" % len(self.stashed_nodes) + hash = util.INLINE_PLACEHOLDER % id + return hash, id + + def __findPlaceholder(self, data: str, index: int) -> tuple[str | None, int]: + """ + Extract id from data string, start from index. + + Arguments: + data: String. + index: Index, from which we start search. + + Returns: + Placeholder id and string index, after the found placeholder. + + """ + m = self.__placeholder_re.search(data, index) + if m: + return m.group(1), m.end() + else: + return None, index + 1 + + def __stashNode(self, node: etree.Element | str, type: str) -> str: + """ Add node to stash. """ + placeholder, id = self.__makePlaceholder(type) + self.stashed_nodes[id] = node + return placeholder + + def __handleInline(self, data: str, patternIndex: int = 0) -> str: + """ + Process string with inline patterns and replace it with placeholders. + + Arguments: + data: A line of Markdown text. + patternIndex: The index of the `inlinePattern` to start with. + + Returns: + String with placeholders. + + """ + if not isinstance(data, util.AtomicString): + startIndex = 0 + count = len(self.inlinePatterns) + while patternIndex < count: + data, matched, startIndex = self.__applyPattern( + self.inlinePatterns[patternIndex], data, patternIndex, startIndex + ) + if not matched: + patternIndex += 1 + return data + + def __processElementText(self, node: etree.Element, subnode: etree.Element, isText: bool = True) -> None: + """ + Process placeholders in `Element.text` or `Element.tail` + of Elements popped from `self.stashed_nodes`. + + Arguments: + node: Parent node. + subnode: Processing node. + isText: Boolean variable, True - it's text, False - it's a tail. + + """ + if isText: + text = subnode.text + subnode.text = None + else: + text = subnode.tail + subnode.tail = None + + childResult = self.__processPlaceholders(text, subnode, isText) + + if not isText and node is not subnode: + pos = list(node).index(subnode) + 1 + else: + pos = 0 + + childResult.reverse() + for newChild in childResult: + node.insert(pos, newChild[0]) + + def __processPlaceholders( + self, + data: str | None, + parent: etree.Element, + isText: bool = True + ) -> list[tuple[etree.Element, list[str]]]: + """ + Process string with placeholders and generate `ElementTree` tree. + + Arguments: + data: String with placeholders instead of `ElementTree` elements. + parent: Element, which contains processing inline data. + isText: Boolean variable, True - it's text, False - it's a tail. + + Returns: + List with `ElementTree` elements with applied inline patterns. + + """ + def linkText(text: str | None) -> None: + if text: + if result: + if result[-1][0].tail: + result[-1][0].tail += text + else: + result[-1][0].tail = text + elif not isText: + if parent.tail: + parent.tail += text + else: + parent.tail = text + else: + if parent.text: + parent.text += text + else: + parent.text = text + result = [] + strartIndex = 0 + while data: + index = data.find(self.__placeholder_prefix, strartIndex) + if index != -1: + id, phEndIndex = self.__findPlaceholder(data, index) + + if id in self.stashed_nodes: + node = self.stashed_nodes.get(id) + + if index > 0: + text = data[strartIndex:index] + linkText(text) + + if not isinstance(node, str): # it's Element + for child in [node] + list(node): + if child.tail: + if child.tail.strip(): + self.__processElementText( + node, child, False + ) + if child.text: + if child.text.strip(): + self.__processElementText(child, child) + else: # it's just a string + linkText(node) + strartIndex = phEndIndex + continue + + strartIndex = phEndIndex + result.append((node, self.ancestors[:])) + + else: # wrong placeholder + end = index + len(self.__placeholder_prefix) + linkText(data[strartIndex:end]) + strartIndex = end + else: + text = data[strartIndex:] + if isinstance(data, util.AtomicString): + # We don't want to loose the `AtomicString` + text = util.AtomicString(text) + linkText(text) + data = "" + + return result + + def __applyPattern( + self, + pattern: inlinepatterns.Pattern, + data: str, + patternIndex: int, + startIndex: int = 0 + ) -> tuple[str, bool, int]: + """ + Check if the line fits the pattern, create the necessary + elements, add it to `stashed_nodes`. + + Arguments: + data: The text to be processed. + pattern: The pattern to be checked. + patternIndex: Index of current pattern. + startIndex: String index, from which we start searching. + + Returns: + String with placeholders instead of `ElementTree` elements. + + """ + new_style = isinstance(pattern, inlinepatterns.InlineProcessor) + + for exclude in pattern.ANCESTOR_EXCLUDES: + if exclude.lower() in self.ancestors: + return data, False, 0 + + if new_style: + match = None + # Since `handleMatch` may reject our first match, + # we iterate over the buffer looking for matches + # until we can't find any more. + for match in pattern.getCompiledRegExp().finditer(data, startIndex): + node, start, end = pattern.handleMatch(match, data) + if start is None or end is None: + startIndex += match.end(0) + match = None + continue + break + else: # pragma: no cover + match = pattern.getCompiledRegExp().match(data[startIndex:]) + leftData = data[:startIndex] + + if not match: + return data, False, 0 + + if not new_style: # pragma: no cover + node = pattern.handleMatch(match) + start = match.start(0) + end = match.end(0) + + if node is None: + return data, True, end + + if not isinstance(node, str): + if not isinstance(node.text, util.AtomicString): + # We need to process current node too + for child in [node] + list(node): + if not isString(node): + if child.text: + self.ancestors.append(child.tag.lower()) + child.text = self.__handleInline( + child.text, patternIndex + 1 + ) + self.ancestors.pop() + if child.tail: + child.tail = self.__handleInline( + child.tail, patternIndex + ) + + placeholder = self.__stashNode(node, pattern.type()) + + if new_style: + return "{}{}{}".format(data[:start], + placeholder, data[end:]), True, 0 + else: # pragma: no cover + return "{}{}{}{}".format(leftData, + match.group(1), + placeholder, match.groups()[-1]), True, 0 + + def __build_ancestors(self, parent: etree.Element | None, parents: list[str]) -> None: + """Build the ancestor list.""" + ancestors = [] + while parent is not None: + if parent is not None: + ancestors.append(parent.tag.lower()) + parent = self.parent_map.get(parent) + ancestors.reverse() + parents.extend(ancestors) + + def run(self, tree: etree.Element, ancestors: list[str] | None = None) -> etree.Element: + """Apply inline patterns to a parsed Markdown tree. + + Iterate over `Element`, find elements with inline tag, apply inline + patterns and append newly created Elements to tree. To avoid further + processing of string with inline patterns, instead of normal string, + use subclass [`AtomicString`][markdown.util.AtomicString]: + + node.text = markdown.util.AtomicString("This will not be processed.") + + Arguments: + tree: `Element` object, representing Markdown tree. + ancestors: List of parent tag names that precede the tree node (if needed). + + Returns: + An element tree object with applied inline patterns. + + """ + self.stashed_nodes: dict[str, etree.Element | str] = {} + + # Ensure a valid parent list, but copy passed in lists + # to ensure we don't have the user accidentally change it on us. + tree_parents = [] if ancestors is None else ancestors[:] + + self.parent_map = {c: p for p in tree.iter() for c in p} + stack = [(tree, tree_parents)] + + while stack: + currElement, parents = stack.pop(0) + + self.ancestors = parents + self.__build_ancestors(currElement, self.ancestors) + + insertQueue = [] + for child in currElement: + if child.text and not isinstance( + child.text, util.AtomicString + ): + self.ancestors.append(child.tag.lower()) + text = child.text + child.text = None + lst = self.__processPlaceholders( + self.__handleInline(text), child + ) + for item in lst: + self.parent_map[item[0]] = child + stack += lst + insertQueue.append((child, lst)) + self.ancestors.pop() + if child.tail: + tail = self.__handleInline(child.tail) + dumby = etree.Element('d') + child.tail = None + tailResult = self.__processPlaceholders(tail, dumby, False) + if dumby.tail: + child.tail = dumby.tail + pos = list(currElement).index(child) + 1 + tailResult.reverse() + for newChild in tailResult: + self.parent_map[newChild[0]] = currElement + currElement.insert(pos, newChild[0]) + if len(child): + self.parent_map[child] = currElement + stack.append((child, self.ancestors[:])) + + for element, lst in insertQueue: + for i, obj in enumerate(lst): + newChild = obj[0] + element.insert(i, newChild) + return tree + + +class PrettifyTreeprocessor(Treeprocessor): + """ Add line breaks to the html document. """ + + def _prettifyETree(self, elem: etree.Element) -> None: + """ Recursively add line breaks to `ElementTree` children. """ + + i = "\n" + if self.md.is_block_level(elem.tag) and elem.tag not in ['code', 'pre']: + if (not elem.text or not elem.text.strip()) \ + and len(elem) and self.md.is_block_level(elem[0].tag): + elem.text = i + for e in elem: + if self.md.is_block_level(e.tag): + self._prettifyETree(e) + if not elem.tail or not elem.tail.strip(): + elem.tail = i + + def run(self, root: etree.Element) -> None: + """ Add line breaks to `Element` object and its children. """ + + self._prettifyETree(root) + # Do `<br />`'s separately as they are often in the middle of + # inline content and missed by `_prettifyETree`. + brs = root.iter('br') + for br in brs: + if not br.tail or not br.tail.strip(): + br.tail = '\n' + else: + br.tail = '\n%s' % br.tail + # Clean up extra empty lines at end of code blocks. + pres = root.iter('pre') + for pre in pres: + if len(pre) and pre[0].tag == 'code': + code = pre[0] + # Only prettify code containing text only + if not len(code) and code.text is not None: + code.text = util.AtomicString(code.text.rstrip() + '\n') + + +class UnescapeTreeprocessor(Treeprocessor): + """ Restore escaped chars """ + + RE = re.compile(r'{}(\d+){}'.format(util.STX, util.ETX)) + + def _unescape(self, m: re.Match[str]) -> str: + return chr(int(m.group(1))) + + def unescape(self, text: str) -> str: + return self.RE.sub(self._unescape, text) + + def run(self, root: etree.Element) -> None: + """ Loop over all elements and unescape all text. """ + for elem in root.iter(): + # Unescape text content + if elem.text and not elem.tag == 'code': + elem.text = self.unescape(elem.text) + # Unescape tail content + if elem.tail: + elem.tail = self.unescape(elem.tail) + # Unescape attribute values + for key, value in elem.items(): + elem.set(key, self.unescape(value)) diff --git a/lib/python3.12/site-packages/markdown/util.py b/lib/python3.12/site-packages/markdown/util.py new file mode 100644 index 0000000000000000000000000000000000000000..f547721eb74c8e0b93dcd5aef7fd20f1c083768e --- /dev/null +++ b/lib/python3.12/site-packages/markdown/util.py @@ -0,0 +1,409 @@ +# Python Markdown + +# A Python implementation of John Gruber's Markdown. + +# Documentation: https://python-markdown.github.io/ +# GitHub: https://github.com/Python-Markdown/markdown/ +# PyPI: https://pypi.org/project/Markdown/ + +# Started by Manfred Stienstra (http://www.dwerg.net/). +# Maintained for a few years by Yuri Takhteyev (http://www.freewisdom.org). +# Currently maintained by Waylan Limberg (https://github.com/waylan), +# Dmitry Shachnev (https://github.com/mitya57) and Isaac Muse (https://github.com/facelessuser). + +# Copyright 2007-2023 The Python Markdown Project (v. 1.7 and later) +# Copyright 2004, 2005, 2006 Yuri Takhteyev (v. 0.2-1.6b) +# Copyright 2004 Manfred Stienstra (the original version) + +# License: BSD (see LICENSE.md for details). + +""" +This module contains various contacts, classes and functions which get referenced and used +throughout the code base. +""" + +from __future__ import annotations + +import re +import sys +import warnings +from functools import wraps, lru_cache +from itertools import count +from typing import TYPE_CHECKING, Generic, Iterator, NamedTuple, TypeVar, TypedDict, overload + +if TYPE_CHECKING: # pragma: no cover + from markdown import Markdown + import xml.etree.ElementTree as etree + +_T = TypeVar('_T') + + +""" +Constants you might want to modify +----------------------------------------------------------------------------- +""" + + +BLOCK_LEVEL_ELEMENTS: list[str] = [ + # Elements which are invalid to wrap in a `<p>` tag. + # See https://w3c.github.io/html/grouping-content.html#the-p-element + 'address', 'article', 'aside', 'blockquote', 'details', 'div', 'dl', + 'fieldset', 'figcaption', 'figure', 'footer', 'form', 'h1', 'h2', 'h3', + 'h4', 'h5', 'h6', 'header', 'hgroup', 'hr', 'main', 'menu', 'nav', 'ol', + 'p', 'pre', 'section', 'table', 'ul', + # Other elements which Markdown should not be mucking up the contents of. + 'canvas', 'colgroup', 'dd', 'body', 'dt', 'group', 'html', 'iframe', 'li', 'legend', + 'math', 'map', 'noscript', 'output', 'object', 'option', 'progress', 'script', + 'style', 'summary', 'tbody', 'td', 'textarea', 'tfoot', 'th', 'thead', 'tr', 'video', + 'center' +] +""" +List of HTML tags which get treated as block-level elements. Same as the `block_level_elements` +attribute of the [`Markdown`][markdown.Markdown] class. Generally one should use the +attribute on the class. This remains for compatibility with older extensions. +""" + +# Placeholders +STX = '\u0002' +""" "Start of Text" marker for placeholder templates. """ +ETX = '\u0003' +""" "End of Text" marker for placeholder templates. """ +INLINE_PLACEHOLDER_PREFIX = STX+"klzzwxh:" +""" Prefix for inline placeholder template. """ +INLINE_PLACEHOLDER = INLINE_PLACEHOLDER_PREFIX + "%s" + ETX +""" Placeholder template for stashed inline text. """ +INLINE_PLACEHOLDER_RE = re.compile(INLINE_PLACEHOLDER % r'([0-9]+)') +""" Regular Expression which matches inline placeholders. """ +AMP_SUBSTITUTE = STX+"amp"+ETX +""" Placeholder template for HTML entities. """ +HTML_PLACEHOLDER = STX + "wzxhzdk:%s" + ETX +""" Placeholder template for raw HTML. """ +HTML_PLACEHOLDER_RE = re.compile(HTML_PLACEHOLDER % r'([0-9]+)') +""" Regular expression which matches HTML placeholders. """ +TAG_PLACEHOLDER = STX + "hzzhzkh:%s" + ETX +""" Placeholder template for tags. """ + + +# Constants you probably do not need to change +# ----------------------------------------------------------------------------- + +RTL_BIDI_RANGES = ( + ('\u0590', '\u07FF'), + # Hebrew (0590-05FF), Arabic (0600-06FF), + # Syriac (0700-074F), Arabic supplement (0750-077F), + # Thaana (0780-07BF), Nko (07C0-07FF). + ('\u2D30', '\u2D7F') # Tifinagh +) + + +# AUXILIARY GLOBAL FUNCTIONS +# ============================================================================= + + +@lru_cache(maxsize=None) +def get_installed_extensions(): + """ Return all entry_points in the `markdown.extensions` group. """ + if sys.version_info >= (3, 10): + from importlib import metadata + else: # `<PY310` use backport + import importlib_metadata as metadata + # Only load extension entry_points once. + return metadata.entry_points(group='markdown.extensions') + + +def deprecated(message: str, stacklevel: int = 2): + """ + Raise a [`DeprecationWarning`][] when wrapped function/method is called. + + Usage: + + ```python + @deprecated("This method will be removed in version X; use Y instead.") + def some_method(): + pass + ``` + """ + def wrapper(func): + @wraps(func) + def deprecated_func(*args, **kwargs): + warnings.warn( + f"'{func.__name__}' is deprecated. {message}", + category=DeprecationWarning, + stacklevel=stacklevel + ) + return func(*args, **kwargs) + return deprecated_func + return wrapper + + +def parseBoolValue(value: str | None, fail_on_errors: bool = True, preserve_none: bool = False) -> bool | None: + """Parses a string representing a boolean value. If parsing was successful, + returns `True` or `False`. If `preserve_none=True`, returns `True`, `False`, + or `None`. If parsing was not successful, raises `ValueError`, or, if + `fail_on_errors=False`, returns `None`.""" + if not isinstance(value, str): + if preserve_none and value is None: + return value + return bool(value) + elif preserve_none and value.lower() == 'none': + return None + elif value.lower() in ('true', 'yes', 'y', 'on', '1'): + return True + elif value.lower() in ('false', 'no', 'n', 'off', '0', 'none'): + return False + elif fail_on_errors: + raise ValueError('Cannot parse bool value: %r' % value) + + +def code_escape(text: str) -> str: + """HTML escape a string of code.""" + if "&" in text: + text = text.replace("&", "&") + if "<" in text: + text = text.replace("<", "<") + if ">" in text: + text = text.replace(">", ">") + return text + + +def _get_stack_depth(size: int = 2) -> int: + """Get current stack depth, performantly. + """ + frame = sys._getframe(size) + + for size in count(size): + frame = frame.f_back + if not frame: + return size + + +def nearing_recursion_limit() -> bool: + """Return true if current stack depth is within 100 of maximum limit.""" + return sys.getrecursionlimit() - _get_stack_depth() < 100 + + +# MISC AUXILIARY CLASSES +# ============================================================================= + + +class AtomicString(str): + """A string which should not be further processed.""" + pass + + +class Processor: + """ The base class for all processors. + + Attributes: + Processor.md: The `Markdown` instance passed in an initialization. + + Arguments: + md: The `Markdown` instance this processor is a part of. + + """ + def __init__(self, md: Markdown | None = None): + self.md = md + + +if TYPE_CHECKING: # pragma: no cover + class TagData(TypedDict): + tag: str + attrs: dict[str, str] + left_index: int + right_index: int + + +class HtmlStash: + """ + This class is used for stashing HTML objects that we extract + in the beginning and replace with place-holders. + """ + + def __init__(self): + """ Create an `HtmlStash`. """ + self.html_counter = 0 # for counting inline html segments + self.rawHtmlBlocks: list[str | etree.Element] = [] + self.tag_counter = 0 + self.tag_data: list[TagData] = [] # list of dictionaries in the order tags appear + + def store(self, html: str | etree.Element) -> str: + """ + Saves an HTML segment for later reinsertion. Returns a + placeholder string that needs to be inserted into the + document. + + Keyword arguments: + html: An html segment. + + Returns: + A placeholder string. + + """ + self.rawHtmlBlocks.append(html) + placeholder = self.get_placeholder(self.html_counter) + self.html_counter += 1 + return placeholder + + def reset(self) -> None: + """ Clear the stash. """ + self.html_counter = 0 + self.rawHtmlBlocks = [] + + def get_placeholder(self, key: int) -> str: + return HTML_PLACEHOLDER % key + + def store_tag(self, tag: str, attrs: dict[str, str], left_index: int, right_index: int) -> str: + """Store tag data and return a placeholder.""" + self.tag_data.append({'tag': tag, 'attrs': attrs, + 'left_index': left_index, + 'right_index': right_index}) + placeholder = TAG_PLACEHOLDER % str(self.tag_counter) + self.tag_counter += 1 # equal to the tag's index in `self.tag_data` + return placeholder + + +# Used internally by `Registry` for each item in its sorted list. +# Provides an easier to read API when editing the code later. +# For example, `item.name` is more clear than `item[0]`. +class _PriorityItem(NamedTuple): + name: str + priority: float + + +class Registry(Generic[_T]): + """ + A priority sorted registry. + + A `Registry` instance provides two public methods to alter the data of the + registry: `register` and `deregister`. Use `register` to add items and + `deregister` to remove items. See each method for specifics. + + When registering an item, a "name" and a "priority" must be provided. All + items are automatically sorted by "priority" from highest to lowest. The + "name" is used to remove ("deregister") and get items. + + A `Registry` instance it like a list (which maintains order) when reading + data. You may iterate over the items, get an item and get a count (length) + of all items. You may also check that the registry contains an item. + + When getting an item you may use either the index of the item or the + string-based "name". For example: + + registry = Registry() + registry.register(SomeItem(), 'itemname', 20) + # Get the item by index + item = registry[0] + # Get the item by name + item = registry['itemname'] + + When checking that the registry contains an item, you may use either the + string-based "name", or a reference to the actual item. For example: + + someitem = SomeItem() + registry.register(someitem, 'itemname', 20) + # Contains the name + assert 'itemname' in registry + # Contains the item instance + assert someitem in registry + + The method `get_index_for_name` is also available to obtain the index of + an item using that item's assigned "name". + """ + + def __init__(self): + self._data: dict[str, _T] = {} + self._priority: list[_PriorityItem] = [] + self._is_sorted = False + + def __contains__(self, item: str | _T) -> bool: + if isinstance(item, str): + # Check if an item exists by this name. + return item in self._data.keys() + # Check if this instance exists. + return item in self._data.values() + + def __iter__(self) -> Iterator[_T]: + self._sort() + return iter([self._data[k] for k, p in self._priority]) + + @overload + def __getitem__(self, key: str | int) -> _T: # pragma: no cover + ... + + @overload + def __getitem__(self, key: slice) -> Registry[_T]: # pragma: no cover + ... + + def __getitem__(self, key: str | int | slice) -> _T | Registry[_T]: + self._sort() + if isinstance(key, slice): + data: Registry[_T] = Registry() + for k, p in self._priority[key]: + data.register(self._data[k], k, p) + return data + if isinstance(key, int): + return self._data[self._priority[key].name] + return self._data[key] + + def __len__(self) -> int: + return len(self._priority) + + def __repr__(self): + return '<{}({})>'.format(self.__class__.__name__, list(self)) + + def get_index_for_name(self, name: str) -> int: + """ + Return the index of the given name. + """ + if name in self: + self._sort() + return self._priority.index( + [x for x in self._priority if x.name == name][0] + ) + raise ValueError('No item named "{}" exists.'.format(name)) + + def register(self, item: _T, name: str, priority: float) -> None: + """ + Add an item to the registry with the given name and priority. + + Arguments: + item: The item being registered. + name: A string used to reference the item. + priority: An integer or float used to sort against all items. + + If an item is registered with a "name" which already exists, the + existing item is replaced with the new item. Treat carefully as the + old item is lost with no way to recover it. The new item will be + sorted according to its priority and will **not** retain the position + of the old item. + """ + if name in self: + # Remove existing item of same name first + self.deregister(name) + self._is_sorted = False + self._data[name] = item + self._priority.append(_PriorityItem(name, priority)) + + def deregister(self, name: str, strict: bool = True) -> None: + """ + Remove an item from the registry. + + Set `strict=False` to fail silently. Otherwise a [`ValueError`][] is raised for an unknown `name`. + """ + try: + index = self.get_index_for_name(name) + del self._priority[index] + del self._data[name] + except ValueError: + if strict: + raise + + def _sort(self) -> None: + """ + Sort the registry by priority from highest to lowest. + + This method is called internally and should never be explicitly called. + """ + if not self._is_sorted: + self._priority.sort(key=lambda item: item.priority, reverse=True) + self._is_sorted = True diff --git a/lib/python3.12/site-packages/matplotlib-3.10.3.dist-info/INSTALLER b/lib/python3.12/site-packages/matplotlib-3.10.3.dist-info/INSTALLER new file mode 100644 index 0000000000000000000000000000000000000000..a1b589e38a32041e49332e5e81c2d363dc418d68 --- /dev/null +++ b/lib/python3.12/site-packages/matplotlib-3.10.3.dist-info/INSTALLER @@ -0,0 +1 @@ +pip diff --git a/lib/python3.12/site-packages/matplotlib-3.10.3.dist-info/LICENSE b/lib/python3.12/site-packages/matplotlib-3.10.3.dist-info/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..ec51537db27dd4d9c9ed3cd39fd96485f3cfddea --- /dev/null +++ b/lib/python3.12/site-packages/matplotlib-3.10.3.dist-info/LICENSE @@ -0,0 +1,99 @@ +License agreement for matplotlib versions 1.3.0 and later +========================================================= + +1. This LICENSE AGREEMENT is between the Matplotlib Development Team +("MDT"), and the Individual or Organization ("Licensee") accessing and +otherwise using matplotlib software in source or binary form and its +associated documentation. + +2. Subject to the terms and conditions of this License Agreement, MDT +hereby grants Licensee a nonexclusive, royalty-free, world-wide license +to reproduce, analyze, test, perform and/or display publicly, prepare +derivative works, distribute, and otherwise use matplotlib +alone or in any derivative version, provided, however, that MDT's +License Agreement and MDT's notice of copyright, i.e., "Copyright (c) +2012- Matplotlib Development Team; All Rights Reserved" are retained in +matplotlib alone or in any derivative version prepared by +Licensee. + +3. In the event Licensee prepares a derivative work that is based on or +incorporates matplotlib or any part thereof, and wants to +make the derivative work available to others as provided herein, then +Licensee hereby agrees to include in any such work a brief summary of +the changes made to matplotlib . + +4. MDT is making matplotlib available to Licensee on an "AS +IS" basis. MDT MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR +IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, MDT MAKES NO AND +DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS +FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF MATPLOTLIB +WILL NOT INFRINGE ANY THIRD PARTY RIGHTS. + +5. MDT SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF MATPLOTLIB + FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR +LOSS AS A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING +MATPLOTLIB , OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF +THE POSSIBILITY THEREOF. + +6. This License Agreement will automatically terminate upon a material +breach of its terms and conditions. + +7. Nothing in this License Agreement shall be deemed to create any +relationship of agency, partnership, or joint venture between MDT and +Licensee. This License Agreement does not grant permission to use MDT +trademarks or trade name in a trademark sense to endorse or promote +products or services of Licensee, or any third party. + +8. By copying, installing or otherwise using matplotlib , +Licensee agrees to be bound by the terms and conditions of this License +Agreement. + +License agreement for matplotlib versions prior to 1.3.0 +======================================================== + +1. This LICENSE AGREEMENT is between John D. Hunter ("JDH"), and the +Individual or Organization ("Licensee") accessing and otherwise using +matplotlib software in source or binary form and its associated +documentation. + +2. Subject to the terms and conditions of this License Agreement, JDH +hereby grants Licensee a nonexclusive, royalty-free, world-wide license +to reproduce, analyze, test, perform and/or display publicly, prepare +derivative works, distribute, and otherwise use matplotlib +alone or in any derivative version, provided, however, that JDH's +License Agreement and JDH's notice of copyright, i.e., "Copyright (c) +2002-2011 John D. Hunter; All Rights Reserved" are retained in +matplotlib alone or in any derivative version prepared by +Licensee. + +3. In the event Licensee prepares a derivative work that is based on or +incorporates matplotlib or any part thereof, and wants to +make the derivative work available to others as provided herein, then +Licensee hereby agrees to include in any such work a brief summary of +the changes made to matplotlib. + +4. JDH is making matplotlib available to Licensee on an "AS +IS" basis. JDH MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR +IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, JDH MAKES NO AND +DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS +FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF MATPLOTLIB +WILL NOT INFRINGE ANY THIRD PARTY RIGHTS. + +5. JDH SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF MATPLOTLIB + FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR +LOSS AS A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING +MATPLOTLIB , OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF +THE POSSIBILITY THEREOF. + +6. This License Agreement will automatically terminate upon a material +breach of its terms and conditions. + +7. Nothing in this License Agreement shall be deemed to create any +relationship of agency, partnership, or joint venture between JDH and +Licensee. This License Agreement does not grant permission to use JDH +trademarks or trade name in a trademark sense to endorse or promote +products or services of Licensee, or any third party. + +8. By copying, installing or otherwise using matplotlib, +Licensee agrees to be bound by the terms and conditions of this License +Agreement. \ No newline at end of file diff --git a/lib/python3.12/site-packages/matplotlib-3.10.3.dist-info/METADATA b/lib/python3.12/site-packages/matplotlib-3.10.3.dist-info/METADATA new file mode 100644 index 0000000000000000000000000000000000000000..49dfd37a2c6a647cd3d2bd68b8fc56edbe4764af --- /dev/null +++ b/lib/python3.12/site-packages/matplotlib-3.10.3.dist-info/METADATA @@ -0,0 +1,215 @@ +Metadata-Version: 2.1 +Name: matplotlib +Version: 3.10.3 +Summary: Python plotting package +Author: John D. Hunter, Michael Droettboom +Author-Email: Unknown <matplotlib-users@python.org> +License: License agreement for matplotlib versions 1.3.0 and later + ========================================================= + + 1. This LICENSE AGREEMENT is between the Matplotlib Development Team + ("MDT"), and the Individual or Organization ("Licensee") accessing and + otherwise using matplotlib software in source or binary form and its + associated documentation. + + 2. Subject to the terms and conditions of this License Agreement, MDT + hereby grants Licensee a nonexclusive, royalty-free, world-wide license + to reproduce, analyze, test, perform and/or display publicly, prepare + derivative works, distribute, and otherwise use matplotlib + alone or in any derivative version, provided, however, that MDT's + License Agreement and MDT's notice of copyright, i.e., "Copyright (c) + 2012- Matplotlib Development Team; All Rights Reserved" are retained in + matplotlib alone or in any derivative version prepared by + Licensee. + + 3. In the event Licensee prepares a derivative work that is based on or + incorporates matplotlib or any part thereof, and wants to + make the derivative work available to others as provided herein, then + Licensee hereby agrees to include in any such work a brief summary of + the changes made to matplotlib . + + 4. MDT is making matplotlib available to Licensee on an "AS + IS" basis. MDT MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR + IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, MDT MAKES NO AND + DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS + FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF MATPLOTLIB + WILL NOT INFRINGE ANY THIRD PARTY RIGHTS. + + 5. MDT SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF MATPLOTLIB + FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR + LOSS AS A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING + MATPLOTLIB , OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF + THE POSSIBILITY THEREOF. + + 6. This License Agreement will automatically terminate upon a material + breach of its terms and conditions. + + 7. Nothing in this License Agreement shall be deemed to create any + relationship of agency, partnership, or joint venture between MDT and + Licensee. This License Agreement does not grant permission to use MDT + trademarks or trade name in a trademark sense to endorse or promote + products or services of Licensee, or any third party. + + 8. By copying, installing or otherwise using matplotlib , + Licensee agrees to be bound by the terms and conditions of this License + Agreement. + + License agreement for matplotlib versions prior to 1.3.0 + ======================================================== + + 1. This LICENSE AGREEMENT is between John D. Hunter ("JDH"), and the + Individual or Organization ("Licensee") accessing and otherwise using + matplotlib software in source or binary form and its associated + documentation. + + 2. Subject to the terms and conditions of this License Agreement, JDH + hereby grants Licensee a nonexclusive, royalty-free, world-wide license + to reproduce, analyze, test, perform and/or display publicly, prepare + derivative works, distribute, and otherwise use matplotlib + alone or in any derivative version, provided, however, that JDH's + License Agreement and JDH's notice of copyright, i.e., "Copyright (c) + 2002-2011 John D. Hunter; All Rights Reserved" are retained in + matplotlib alone or in any derivative version prepared by + Licensee. + + 3. In the event Licensee prepares a derivative work that is based on or + incorporates matplotlib or any part thereof, and wants to + make the derivative work available to others as provided herein, then + Licensee hereby agrees to include in any such work a brief summary of + the changes made to matplotlib. + + 4. JDH is making matplotlib available to Licensee on an "AS + IS" basis. JDH MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR + IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, JDH MAKES NO AND + DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS + FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF MATPLOTLIB + WILL NOT INFRINGE ANY THIRD PARTY RIGHTS. + + 5. JDH SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF MATPLOTLIB + FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR + LOSS AS A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING + MATPLOTLIB , OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF + THE POSSIBILITY THEREOF. + + 6. This License Agreement will automatically terminate upon a material + breach of its terms and conditions. + + 7. Nothing in this License Agreement shall be deemed to create any + relationship of agency, partnership, or joint venture between JDH and + Licensee. This License Agreement does not grant permission to use JDH + trademarks or trade name in a trademark sense to endorse or promote + products or services of Licensee, or any third party. + + 8. By copying, installing or otherwise using matplotlib, + Licensee agrees to be bound by the terms and conditions of this License + Agreement. +Classifier: Development Status :: 5 - Production/Stable +Classifier: Framework :: Matplotlib +Classifier: Intended Audience :: Science/Research +Classifier: Intended Audience :: Education +Classifier: License :: OSI Approved :: Python Software Foundation License +Classifier: Programming Language :: Python +Classifier: Programming Language :: Python :: 3 +Classifier: Programming Language :: Python :: 3.10 +Classifier: Programming Language :: Python :: 3.11 +Classifier: Programming Language :: Python :: 3.12 +Classifier: Programming Language :: Python :: 3.13 +Classifier: Topic :: Scientific/Engineering :: Visualization +Project-URL: Homepage, https://matplotlib.org +Project-URL: Download, https://matplotlib.org/stable/install/index.html +Project-URL: Documentation, https://matplotlib.org +Project-URL: Source Code, https://github.com/matplotlib/matplotlib +Project-URL: Bug Tracker, https://github.com/matplotlib/matplotlib/issues +Project-URL: Forum, https://discourse.matplotlib.org/ +Project-URL: Donate, https://numfocus.org/donate-to-matplotlib +Requires-Python: >=3.10 +Requires-Dist: contourpy>=1.0.1 +Requires-Dist: cycler>=0.10 +Requires-Dist: fonttools>=4.22.0 +Requires-Dist: kiwisolver>=1.3.1 +Requires-Dist: numpy>=1.23 +Requires-Dist: packaging>=20.0 +Requires-Dist: pillow>=8 +Requires-Dist: pyparsing>=2.3.1 +Requires-Dist: python-dateutil>=2.7 +Provides-Extra: dev +Requires-Dist: meson-python<0.17.0,>=0.13.1; extra == "dev" +Requires-Dist: pybind11!=2.13.3,>=2.13.2; extra == "dev" +Requires-Dist: setuptools_scm>=7; extra == "dev" +Requires-Dist: setuptools>=64; extra == "dev" +Description-Content-Type: text/markdown + +[![PyPi](https://img.shields.io/pypi/v/matplotlib)](https://pypi.org/project/matplotlib/) +[![Conda](https://img.shields.io/conda/vn/conda-forge/matplotlib)](https://anaconda.org/conda-forge/matplotlib) +[![Downloads](https://img.shields.io/pypi/dm/matplotlib)](https://pypi.org/project/matplotlib) +[![NUMFocus](https://img.shields.io/badge/powered%20by-NumFOCUS-orange.svg?style=flat&colorA=E1523D&colorB=007D8A)](https://numfocus.org) + +[![Discourse help forum](https://img.shields.io/badge/help_forum-discourse-blue.svg)](https://discourse.matplotlib.org) +[![Gitter](https://badges.gitter.im/matplotlib/matplotlib.svg)](https://gitter.im/matplotlib/matplotlib) +[![GitHub issues](https://img.shields.io/badge/issue_tracking-github-blue.svg)](https://github.com/matplotlib/matplotlib/issues) +[![Contributing](https://img.shields.io/badge/PR-Welcome-%23FF8300.svg?)](https://matplotlib.org/stable/devel/index.html) + +[![GitHub actions status](https://github.com/matplotlib/matplotlib/workflows/Tests/badge.svg)](https://github.com/matplotlib/matplotlib/actions?query=workflow%3ATests) +[![Azure pipelines status](https://dev.azure.com/matplotlib/matplotlib/_apis/build/status/matplotlib.matplotlib?branchName=main)](https://dev.azure.com/matplotlib/matplotlib/_build/latest?definitionId=1&branchName=main) +[![AppVeyor status](https://ci.appveyor.com/api/projects/status/github/matplotlib/matplotlib?branch=main&svg=true)](https://ci.appveyor.com/project/matplotlib/matplotlib) +[![Codecov status](https://codecov.io/github/matplotlib/matplotlib/badge.svg?branch=main&service=github)](https://app.codecov.io/gh/matplotlib/matplotlib) +[![EffVer Versioning](https://img.shields.io/badge/version_scheme-EffVer-0097a7)](https://jacobtomlinson.dev/effver) + +![Matplotlib logotype](https://matplotlib.org/_static/logo2.svg) + +Matplotlib is a comprehensive library for creating static, animated, and +interactive visualizations in Python. + +Check out our [home page](https://matplotlib.org/) for more information. + +![image](https://matplotlib.org/_static/readme_preview.png) + +Matplotlib produces publication-quality figures in a variety of hardcopy +formats and interactive environments across platforms. Matplotlib can be +used in Python scripts, Python/IPython shells, web application servers, +and various graphical user interface toolkits. + +## Install + +See the [install +documentation](https://matplotlib.org/stable/users/installing/index.html), +which is generated from `/doc/install/index.rst` + +## Contribute + +You've discovered a bug or something else you want to change — excellent! + +You've worked out a way to fix it — even better! + +You want to tell us about it — best of all! + +Start at the [contributing +guide](https://matplotlib.org/devdocs/devel/contribute.html)! + +## Contact + +[Discourse](https://discourse.matplotlib.org/) is the discussion forum +for general questions and discussions and our recommended starting +point. + +Our active mailing lists (which are mirrored on Discourse) are: + +- [Users](https://mail.python.org/mailman/listinfo/matplotlib-users) + mailing list: <matplotlib-users@python.org> +- [Announcement](https://mail.python.org/mailman/listinfo/matplotlib-announce) + mailing list: <matplotlib-announce@python.org> +- [Development](https://mail.python.org/mailman/listinfo/matplotlib-devel) + mailing list: <matplotlib-devel@python.org> + +[Gitter](https://gitter.im/matplotlib/matplotlib) is for coordinating +development and asking questions directly related to contributing to +matplotlib. + +## Citing Matplotlib + +If Matplotlib contributes to a project that leads to publication, please +acknowledge this by citing Matplotlib. + +[A ready-made citation +entry](https://matplotlib.org/stable/users/project/citing.html) is +available. diff --git a/lib/python3.12/site-packages/matplotlib-3.10.3.dist-info/RECORD b/lib/python3.12/site-packages/matplotlib-3.10.3.dist-info/RECORD new file mode 100644 index 0000000000000000000000000000000000000000..8ce4530ce1ec253c443817a1e01401fb91ef76ef --- /dev/null +++ b/lib/python3.12/site-packages/matplotlib-3.10.3.dist-info/RECORD @@ -0,0 +1,884 @@ +__pycache__/pylab.cpython-312.pyc,, +matplotlib-3.10.3.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4 +matplotlib-3.10.3.dist-info/LICENSE,sha256=WhqB6jAXKMi7opM9qDLAzWIina8giToCSrPVMkRGjbw,4830 +matplotlib-3.10.3.dist-info/METADATA,sha256=qkW_YmnN-SloTyASHYvAbp53RWGAaintL1IWgoFW7XI,11268 +matplotlib-3.10.3.dist-info/RECORD,, +matplotlib-3.10.3.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +matplotlib-3.10.3.dist-info/WHEEL,sha256=3qIDcXCk577AXiK3pDifO-gE9U_MYWYGgtD78gLa2_U,137 +matplotlib/__init__.py,sha256=bn_36OtZ4TCX21uj49jldcZ5VhbgOgpWGwQolyODom0,55633 +matplotlib/__init__.pyi,sha256=s0YJ8fkGVioZL9bcBtyYgCq_MbY8LRrQUtmaD3XaIxQ,3439 +matplotlib/__pycache__/__init__.cpython-312.pyc,, +matplotlib/__pycache__/_afm.cpython-312.pyc,, +matplotlib/__pycache__/_animation_data.cpython-312.pyc,, +matplotlib/__pycache__/_blocking_input.cpython-312.pyc,, +matplotlib/__pycache__/_cm.cpython-312.pyc,, +matplotlib/__pycache__/_cm_bivar.cpython-312.pyc,, +matplotlib/__pycache__/_cm_listed.cpython-312.pyc,, +matplotlib/__pycache__/_cm_multivar.cpython-312.pyc,, +matplotlib/__pycache__/_color_data.cpython-312.pyc,, +matplotlib/__pycache__/_constrained_layout.cpython-312.pyc,, +matplotlib/__pycache__/_docstring.cpython-312.pyc,, +matplotlib/__pycache__/_enums.cpython-312.pyc,, +matplotlib/__pycache__/_fontconfig_pattern.cpython-312.pyc,, +matplotlib/__pycache__/_internal_utils.cpython-312.pyc,, +matplotlib/__pycache__/_layoutgrid.cpython-312.pyc,, +matplotlib/__pycache__/_mathtext.cpython-312.pyc,, +matplotlib/__pycache__/_mathtext_data.cpython-312.pyc,, +matplotlib/__pycache__/_pylab_helpers.cpython-312.pyc,, +matplotlib/__pycache__/_text_helpers.cpython-312.pyc,, +matplotlib/__pycache__/_tight_bbox.cpython-312.pyc,, +matplotlib/__pycache__/_tight_layout.cpython-312.pyc,, +matplotlib/__pycache__/_type1font.cpython-312.pyc,, +matplotlib/__pycache__/_version.cpython-312.pyc,, +matplotlib/__pycache__/animation.cpython-312.pyc,, +matplotlib/__pycache__/artist.cpython-312.pyc,, +matplotlib/__pycache__/axis.cpython-312.pyc,, +matplotlib/__pycache__/backend_bases.cpython-312.pyc,, +matplotlib/__pycache__/backend_managers.cpython-312.pyc,, +matplotlib/__pycache__/backend_tools.cpython-312.pyc,, +matplotlib/__pycache__/bezier.cpython-312.pyc,, +matplotlib/__pycache__/category.cpython-312.pyc,, +matplotlib/__pycache__/cbook.cpython-312.pyc,, +matplotlib/__pycache__/cm.cpython-312.pyc,, +matplotlib/__pycache__/collections.cpython-312.pyc,, +matplotlib/__pycache__/colorbar.cpython-312.pyc,, +matplotlib/__pycache__/colorizer.cpython-312.pyc,, +matplotlib/__pycache__/colors.cpython-312.pyc,, +matplotlib/__pycache__/container.cpython-312.pyc,, +matplotlib/__pycache__/contour.cpython-312.pyc,, +matplotlib/__pycache__/dates.cpython-312.pyc,, +matplotlib/__pycache__/dviread.cpython-312.pyc,, +matplotlib/__pycache__/figure.cpython-312.pyc,, +matplotlib/__pycache__/font_manager.cpython-312.pyc,, +matplotlib/__pycache__/gridspec.cpython-312.pyc,, +matplotlib/__pycache__/hatch.cpython-312.pyc,, +matplotlib/__pycache__/image.cpython-312.pyc,, +matplotlib/__pycache__/inset.cpython-312.pyc,, +matplotlib/__pycache__/layout_engine.cpython-312.pyc,, +matplotlib/__pycache__/legend.cpython-312.pyc,, +matplotlib/__pycache__/legend_handler.cpython-312.pyc,, +matplotlib/__pycache__/lines.cpython-312.pyc,, +matplotlib/__pycache__/markers.cpython-312.pyc,, +matplotlib/__pycache__/mathtext.cpython-312.pyc,, +matplotlib/__pycache__/mlab.cpython-312.pyc,, +matplotlib/__pycache__/offsetbox.cpython-312.pyc,, +matplotlib/__pycache__/patches.cpython-312.pyc,, +matplotlib/__pycache__/path.cpython-312.pyc,, +matplotlib/__pycache__/patheffects.cpython-312.pyc,, +matplotlib/__pycache__/pylab.cpython-312.pyc,, +matplotlib/__pycache__/pyplot.cpython-312.pyc,, +matplotlib/__pycache__/quiver.cpython-312.pyc,, +matplotlib/__pycache__/rcsetup.cpython-312.pyc,, +matplotlib/__pycache__/sankey.cpython-312.pyc,, +matplotlib/__pycache__/scale.cpython-312.pyc,, +matplotlib/__pycache__/spines.cpython-312.pyc,, +matplotlib/__pycache__/stackplot.cpython-312.pyc,, +matplotlib/__pycache__/streamplot.cpython-312.pyc,, +matplotlib/__pycache__/table.cpython-312.pyc,, +matplotlib/__pycache__/texmanager.cpython-312.pyc,, +matplotlib/__pycache__/text.cpython-312.pyc,, +matplotlib/__pycache__/textpath.cpython-312.pyc,, +matplotlib/__pycache__/ticker.cpython-312.pyc,, +matplotlib/__pycache__/transforms.cpython-312.pyc,, +matplotlib/__pycache__/typing.cpython-312.pyc,, +matplotlib/__pycache__/units.cpython-312.pyc,, +matplotlib/__pycache__/widgets.cpython-312.pyc,, +matplotlib/_afm.py,sha256=cWe1Ib37T6ZyHbR6_hPuzAjotMmi32y-kDB-i28iyqE,16692 +matplotlib/_animation_data.py,sha256=JJJbbc-fMdPjkbQ7ng9BHL5i91VTDHQVTtEdWOvWBAI,7986 +matplotlib/_api/__init__.py,sha256=7Fs57FtnCmbyr_NwuGWe0EhfXOYrBw22iKMU9onGc_k,13799 +matplotlib/_api/__init__.pyi,sha256=XNL-oGkk1MZPtSXk3rHlC3jLWZxsElmpNpOongKq8qA,2246 +matplotlib/_api/__pycache__/__init__.cpython-312.pyc,, +matplotlib/_api/__pycache__/deprecation.cpython-312.pyc,, +matplotlib/_api/deprecation.py,sha256=XJxksSV8ukS8kSVmsGPS0zgv1ffFa8WNaRLAk31dmOM,20091 +matplotlib/_api/deprecation.pyi,sha256=A8De57amX2GlZSrDYwVYCMxHFb8AsYGrH0k_bCxuJus,2217 +matplotlib/_blocking_input.py,sha256=VHNsxvX2mTx_xBknd30MSicVlRXS4dCDe9hDctbV5rk,1224 +matplotlib/_c_internal_utils.cpython-312-x86_64-linux-gnu.so,sha256=Bbu6lavaz5uheB2A5yeAFmvr4ePd0ZJeMeWuc8DwE5k,251880 +matplotlib/_c_internal_utils.pyi,sha256=Z3bLs9pMGXrmZjt-4_A-x4321bLP-B54xDbr4PIgUfc,377 +matplotlib/_cm.py,sha256=PuYIAkUpz4u4aiUjvdV5njIfG0J_MQY9pM2Yz3j3KXs,68014 +matplotlib/_cm_bivar.py,sha256=gpmKiSxsWoVGWIifseh0goND7Y7zTWJKsznr_QkEtDg,97461 +matplotlib/_cm_listed.py,sha256=3a02mPUSnOsXkqRFNxKGwIvJCAKY5lezBUrqGcllnvk,135004 +matplotlib/_cm_multivar.py,sha256=0UjNFW7Sytj1t31QD99ebfr-al7NmCpWhKUOyq9VzK8,6630 +matplotlib/_color_data.py,sha256=k-wdTi6ArJxksqBfMT-7Uy2qWz8XX4Th5gsjf32CwmM,34780 +matplotlib/_color_data.pyi,sha256=RdBRk01yuf3jYVlCwG351tIBCxehizkZMnKs9c8gnOw,170 +matplotlib/_constrained_layout.py,sha256=Oci1LYPuos_Mb8YxTMTgfae10UqNV7fdD3qugm8NvNA,31403 +matplotlib/_docstring.py,sha256=u9yJorJidI8k1W8S01SIMqNmh5VOMVc-AqyES66EEdk,4435 +matplotlib/_docstring.pyi,sha256=6ze5DoqZaFy6BQ5Z8vtDiJwSOXhBcpZJlkM5enI9cuE,800 +matplotlib/_enums.py,sha256=euD2sj2FIbQMzIPA4rCrII_y8RVzaMyEBKsSdBQaUb4,6175 +matplotlib/_enums.pyi,sha256=K7j_kDwGOnx37CYnXhwfJ9NJBGeet45KvAj0om05RUs,326 +matplotlib/_fontconfig_pattern.py,sha256=2livocARMbpys8tmicH6wlifcwcNoIstgruSY6DSAfk,4361 +matplotlib/_image.cpython-312-x86_64-linux-gnu.so,sha256=Wp-ZJd9YXIBC8JfiwZTgd44taAd9Wa4SAtSPk15X788,555784 +matplotlib/_image.pyi,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +matplotlib/_internal_utils.py,sha256=nhK6LLWYW93fBcsFiO09JmqFj2rgHEsGYFOeaC7HRKw,2140 +matplotlib/_layoutgrid.py,sha256=mFB9asZVol2aV9hLBpdntuG5x1isKUrajF-3TUhCgso,21676 +matplotlib/_mathtext.py,sha256=JMhC1YX6U-Go409F3JsDXpao-6iP3H93XPXPiQESP_c,107842 +matplotlib/_mathtext_data.py,sha256=9y__7jf3bzgOmD2lEYW3QpgsT-z9QDuiwRLVH7Wq8Pw,65067 +matplotlib/_path.cpython-312-x86_64-linux-gnu.so,sha256=Qlc6dgPWigxczPeVGmdPbKayvljzj_lC_0RTPWlaGpA,487304 +matplotlib/_path.pyi,sha256=yznyfzoUogH9vvi0vK68ga4Shlbrn5UBhAnLX8Ght1o,325 +matplotlib/_pylab_helpers.py,sha256=pJERytHDmXo2VP3sH9Qw6NwJP0hefNPQMUXCVntnoHI,4307 +matplotlib/_pylab_helpers.pyi,sha256=7OZKr-OL3ipVt1EDZ6e-tRwHASz-ijYfcIdlPczXhvQ,1012 +matplotlib/_qhull.cpython-312-x86_64-linux-gnu.so,sha256=dTYNClaZ7nrEI6743EO_8ldORWwVWB1Fvz1i9GOgjfY,745688 +matplotlib/_qhull.pyi,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +matplotlib/_text_helpers.py,sha256=nz1pvEp8756PCrn6KIY2M6vVA-beYUNODvvf4NnGF0g,2538 +matplotlib/_tight_bbox.py,sha256=ddJ5ViXulbPLocHr-RkWK27WJSuV2WXUx74jZyL0NOg,2787 +matplotlib/_tight_layout.py,sha256=A3vZKBmxci7w35OO9lxgfUqrRKgTr-N_dUza0M05WxE,12675 +matplotlib/_tri.cpython-312-x86_64-linux-gnu.so,sha256=l_FgFpnxonse3LPSVspzEZEcBIVrWOLDZRRx7OCh-X8,411256 +matplotlib/_tri.pyi,sha256=bFnu97pIlSboJT3ijQBw4YlZdlR1UzXYtI3DkEPVzeM,1429 +matplotlib/_type1font.py,sha256=c_PXvyOFQ4YCRQJ23snWNkdEhuZ4k0JbLe55zaeoXfQ,28409 +matplotlib/_version.py,sha256=g0JScnasC4-IPTQGVDcnvq-usEhkFWYJroH5FWeyAEI,19 +matplotlib/animation.py,sha256=58vUSy_0M2Ve2GO5JcXsCHxCwr7TqaaMed-KNxN6Nno,73114 +matplotlib/animation.pyi,sha256=C8Fgmswh4um-qYgqPZNBEpXVaefRXi6e026pYjonLHg,6566 +matplotlib/artist.py,sha256=hfCGETRkHLfP-i4PmmNsc7syXyFTZi4HYvzyE8q9ak0,63293 +matplotlib/artist.pyi,sha256=9sR0w8Kvi1GaFV6IbUzOw9LvQkPEB8FP_uRvUcI6_hg,7336 +matplotlib/axes/__init__.py,sha256=aHE_zIjphIJkW4_1fyoFuEWTb7gCRewK5jNZWJHEdgM,351 +matplotlib/axes/__init__.pyi,sha256=HP1z2v-PboHQS4dQjvJ7XjUjX-zw6taZRTTB9oVKwYE,303 +matplotlib/axes/__pycache__/__init__.cpython-312.pyc,, +matplotlib/axes/__pycache__/_axes.cpython-312.pyc,, +matplotlib/axes/__pycache__/_base.cpython-312.pyc,, +matplotlib/axes/__pycache__/_secondary_axes.cpython-312.pyc,, +matplotlib/axes/_axes.py,sha256=I7TJgEB4v0DGYPVwN-2FkYHREinTLKB0VyHBVvQxi8s,353757 +matplotlib/axes/_axes.pyi,sha256=fvlVj8AnKt52EOCgxJ5N_6_lb9n0gmXlOP7WGf2puNI,26116 +matplotlib/axes/_base.py,sha256=oqAAqzpqcI8PCdM3AC-cYGvXtuUwgdBBiwf88y1qVuE,185372 +matplotlib/axes/_base.pyi,sha256=CGHbNwWXY1wIT4bGqRBc0JJ9vUkYD29tkmbA3Ulw-co,17051 +matplotlib/axes/_secondary_axes.py,sha256=Sew-LK57mDndUXb-9_VHzs5dJLQ6fh4nstZtfpZjui0,11887 +matplotlib/axes/_secondary_axes.pyi,sha256=GtU55YzLNN7XHMRQcAmcAxcdcN-FzHw1RAJpOiTcZFk,1414 +matplotlib/axis.py,sha256=bsPEyn4eMAnkTCbW4rxSm8ysPNYH2p_ur7RN_ZfZRps,104714 +matplotlib/axis.pyi,sha256=UO1-oCeHyB7Mrxxnuo0Qys2u_k8e6qTmz4B6-rHrWdw,10181 +matplotlib/backend_bases.py,sha256=-41KSWRkMNlTne6c5q-hOOn4vW6rdNtOtA1GIIuu7rY,131838 +matplotlib/backend_bases.pyi,sha256=G6MqvmWf-y2SwhuhNF2qN8hmovxAkV1TJMbg4LzU9cM,16270 +matplotlib/backend_managers.py,sha256=RQheCO_cQBlaWsYMbAmswu0UPKU7bmLTI5LEFgotklA,11795 +matplotlib/backend_managers.pyi,sha256=agnuM0wiZRqSgqti2AgbKJijRLvEPNLOrSY8PEwLjFE,2253 +matplotlib/backend_tools.py,sha256=eCx84l8JBYayju5Ry1LUFyKLIrdgxmGUTJw72VNP1yo,33186 +matplotlib/backend_tools.pyi,sha256=lc3W6FcY0XhTb8Z8XAqQ6MBk_SV7WH41t7zjwAlQfmE,4122 +matplotlib/backends/__init__.py,sha256=JowJe-tDrUBMNJTiJATgiEuACpgdxsKnRCYa-nC255A,206 +matplotlib/backends/__pycache__/__init__.cpython-312.pyc,, +matplotlib/backends/__pycache__/_backend_gtk.cpython-312.pyc,, +matplotlib/backends/__pycache__/_backend_pdf_ps.cpython-312.pyc,, +matplotlib/backends/__pycache__/_backend_tk.cpython-312.pyc,, +matplotlib/backends/__pycache__/backend_agg.cpython-312.pyc,, +matplotlib/backends/__pycache__/backend_cairo.cpython-312.pyc,, +matplotlib/backends/__pycache__/backend_gtk3.cpython-312.pyc,, +matplotlib/backends/__pycache__/backend_gtk3agg.cpython-312.pyc,, +matplotlib/backends/__pycache__/backend_gtk3cairo.cpython-312.pyc,, +matplotlib/backends/__pycache__/backend_gtk4.cpython-312.pyc,, +matplotlib/backends/__pycache__/backend_gtk4agg.cpython-312.pyc,, +matplotlib/backends/__pycache__/backend_gtk4cairo.cpython-312.pyc,, +matplotlib/backends/__pycache__/backend_macosx.cpython-312.pyc,, +matplotlib/backends/__pycache__/backend_mixed.cpython-312.pyc,, +matplotlib/backends/__pycache__/backend_nbagg.cpython-312.pyc,, +matplotlib/backends/__pycache__/backend_pdf.cpython-312.pyc,, +matplotlib/backends/__pycache__/backend_pgf.cpython-312.pyc,, +matplotlib/backends/__pycache__/backend_ps.cpython-312.pyc,, +matplotlib/backends/__pycache__/backend_qt.cpython-312.pyc,, +matplotlib/backends/__pycache__/backend_qt5.cpython-312.pyc,, +matplotlib/backends/__pycache__/backend_qt5agg.cpython-312.pyc,, +matplotlib/backends/__pycache__/backend_qt5cairo.cpython-312.pyc,, +matplotlib/backends/__pycache__/backend_qtagg.cpython-312.pyc,, +matplotlib/backends/__pycache__/backend_qtcairo.cpython-312.pyc,, +matplotlib/backends/__pycache__/backend_svg.cpython-312.pyc,, +matplotlib/backends/__pycache__/backend_template.cpython-312.pyc,, +matplotlib/backends/__pycache__/backend_tkagg.cpython-312.pyc,, +matplotlib/backends/__pycache__/backend_tkcairo.cpython-312.pyc,, +matplotlib/backends/__pycache__/backend_webagg.cpython-312.pyc,, +matplotlib/backends/__pycache__/backend_webagg_core.cpython-312.pyc,, +matplotlib/backends/__pycache__/backend_wx.cpython-312.pyc,, +matplotlib/backends/__pycache__/backend_wxagg.cpython-312.pyc,, +matplotlib/backends/__pycache__/backend_wxcairo.cpython-312.pyc,, +matplotlib/backends/__pycache__/qt_compat.cpython-312.pyc,, +matplotlib/backends/__pycache__/registry.cpython-312.pyc,, +matplotlib/backends/_backend_agg.cpython-312-x86_64-linux-gnu.so,sha256=izz0jkmtnGM3C_OO7BbmHsHkW3sdUaQ9nai5pqMgrsw,754952 +matplotlib/backends/_backend_agg.pyi,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +matplotlib/backends/_backend_gtk.py,sha256=zNCmMOgjgZGfAIlxDU4ph02XMSSn0wIUoPQ4wsaeeAg,11274 +matplotlib/backends/_backend_pdf_ps.py,sha256=M0kjQpMlBoAim6Pdw8u3FqUIoXz4UwRv4mSzXPJPI44,5968 +matplotlib/backends/_backend_tk.py,sha256=q8bdB4u3vGROJsYNWQv2WmyrEy1zBavcjfmKI-MSKp8,43291 +matplotlib/backends/_macosx.pyi,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +matplotlib/backends/_tkagg.cpython-312-x86_64-linux-gnu.so,sha256=FX6Ku7qyRrWfowuxseX0LOLVKfQ2e4y2DLz5yR1u7jU,279192 +matplotlib/backends/_tkagg.pyi,sha256=1yKnYKSgNFesrb0ZI5RQCyTO4zONgHW5MrJ9vq4HzyI,379 +matplotlib/backends/backend_agg.py,sha256=Py_rXRbDTP0ZSrNB2O5Tmh5V8G5V9ViXH_YV5PTX9xw,19888 +matplotlib/backends/backend_cairo.py,sha256=Ah5M85Ppa8zYGOtLOizW0gzltjs3b4nD56qFeQR795U,18618 +matplotlib/backends/backend_gtk3.py,sha256=ZHf4CKhDGJvl8J3p6Sx_ZJBTgVetZBbaKWfnXKvNe0c,22277 +matplotlib/backends/backend_gtk3agg.py,sha256=aFJw05L5V-FyFSj_20l_FuC5piPf8fhKzft_m1NMhYc,2463 +matplotlib/backends/backend_gtk3cairo.py,sha256=zPsJzVm750if2LiQ9ybzsbX0rBhkP05XVvR9Lhz65so,1392 +matplotlib/backends/backend_gtk4.py,sha256=3KWHdU9UghUjEt2v3wgQNm7Yr-DrLVBYWcKNaoyfIYc,23026 +matplotlib/backends/backend_gtk4agg.py,sha256=00i3qpIt9Tcf_S74GOWbeckiPlfVJoQ2pBbhXDMthF0,1262 +matplotlib/backends/backend_gtk4cairo.py,sha256=sqWm3WgfNO8EsBjqzsD4U4cAc0f4q5SufYx7ZJacDf0,1125 +matplotlib/backends/backend_macosx.py,sha256=Gjbu1MKXIZpCM_luMG6U7qDgwotcGSegCvYw-NNWxeQ,7397 +matplotlib/backends/backend_mixed.py,sha256=Gf_2BDjy94FZRXALm0X3xRpK_1l8bZGSYCqnrSjrjME,4696 +matplotlib/backends/backend_nbagg.py,sha256=Au9RHfRufpI0ngT4R0K0CUVtAMFi9Bg-YhDunlj_Lko,8000 +matplotlib/backends/backend_pdf.py,sha256=RCpic_1TWngt6ZC4YfscnSXXTndAXWt9qFl0v1aLiiM,105317 +matplotlib/backends/backend_pgf.py,sha256=GT7UHkAtHtnZcyV0GxHtUL9TTre9dji7Q6qexyi4AUU,39567 +matplotlib/backends/backend_ps.py,sha256=JRZZ6hsPcCfZmb-HczqyBulgow15yvoaHQe3Zcl1Zd4,51991 +matplotlib/backends/backend_qt.py,sha256=wEkQo5b5BQ6uzAXzLWIKBI1gINZx6-Y7h0jTJ3kkuMI,41541 +matplotlib/backends/backend_qt5.py,sha256=kzfoo2ksEGsiWAa2LGtZYzKvfzqJJWyGOohohcRAu1g,787 +matplotlib/backends/backend_qt5agg.py,sha256=Vh7H8kqWH4X8a3VX2XZ2Vze9srwJavkNHAZxdJUz_bk,352 +matplotlib/backends/backend_qt5cairo.py,sha256=Go2Y0GVkXh1xh6x4F255_e5Xbwwws-OiD1Fc0805E78,292 +matplotlib/backends/backend_qtagg.py,sha256=ZjPtp5wR6tZGjbngPXRdVXYRhiPPrc5C0q2DmtdRkpY,3413 +matplotlib/backends/backend_qtcairo.py,sha256=e3SUG50VGqo68eS_8ebTCVQPa4AaxLxuo1JiWX4TIWg,1770 +matplotlib/backends/backend_svg.py,sha256=RQvPE9pR0xSu55NIMajX1WBqmmXbUWWwdWr2qMG-63k,51000 +matplotlib/backends/backend_template.py,sha256=qhWvXGiPeO2jvH61L3NCRYLReo--d4WJrQYanIaEmiE,8012 +matplotlib/backends/backend_tkagg.py,sha256=z9gB16fq2d-DUNpbeSDDLWaYmc0Jz3cDqNlBKhnQg0c,592 +matplotlib/backends/backend_tkcairo.py,sha256=JaGGXh8Y5FwVZtgryIucN941Olf_Pn6f4Re7Vuxl1-c,845 +matplotlib/backends/backend_webagg.py,sha256=YzNscpsID8yu0XYO3NXiZLMPZnDKMAKdG_TO2Ya3g4k,11022 +matplotlib/backends/backend_webagg_core.py,sha256=RZisxLS_pocuBRr5_lqBnF91flQA5upI8PiP8-Zitl0,18748 +matplotlib/backends/backend_wx.py,sha256=Y3IM1DEtPTeXOqkyLIGmNuKqDReyHMTtHzqTuwdfPhM,51296 +matplotlib/backends/backend_wxagg.py,sha256=tzcwYyW34j4LPfHm9uhuHwepwZIcspi3y8oPC8FJkdk,1468 +matplotlib/backends/backend_wxcairo.py,sha256=TK-m3S0c1WipfKE2IpIPNeE4hoXPjfMvnWAzHpCXpFs,848 +matplotlib/backends/qt_compat.py,sha256=t_4aD4rg5maItXABLtVG6JIfuQwZnHwhMcjtz6AUwQs,5346 +matplotlib/backends/qt_editor/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +matplotlib/backends/qt_editor/__pycache__/__init__.cpython-312.pyc,, +matplotlib/backends/qt_editor/__pycache__/_formlayout.cpython-312.pyc,, +matplotlib/backends/qt_editor/__pycache__/figureoptions.cpython-312.pyc,, +matplotlib/backends/qt_editor/_formlayout.py,sha256=QmqqqLO6waqeSGOKjDNUwjvon53Z7yqil5AfqfftDWY,20953 +matplotlib/backends/qt_editor/figureoptions.py,sha256=ljd7fCBiE0kk2ATNTaB5Dg8U6Gn7Lz4Vhi3XX-VbXeE,9851 +matplotlib/backends/registry.py,sha256=M2BHhBKbUJ8E2B2QExfVf8NbAK4ZFhJtmKRelBZZDh4,15480 +matplotlib/backends/web_backend/all_figures.html,sha256=44Y-GvIJbNlqQaKSW3kwVKpTxBSG1WsdYz3ZmYHlUsA,1753 +matplotlib/backends/web_backend/css/boilerplate.css,sha256=qui16QXRnQFNJDbcMasfH6KtN9hLjv8883U9cJmsVCE,2310 +matplotlib/backends/web_backend/css/fbm.css,sha256=wa4vNkNv7fQ_TufjJjecFZEzPMR6W8x6uXJga_wQILw,1456 +matplotlib/backends/web_backend/css/mpl.css,sha256=ruca_aA5kNnP-MZmLkriu8teVP1nIgwcFEpoB16j8Z4,1611 +matplotlib/backends/web_backend/css/page.css,sha256=ca3nO3TaPw7865PN5SlGJBTc2H3rBXQCMaFPywX29y4,1623 +matplotlib/backends/web_backend/ipython_inline_figure.html,sha256=wgSxUh3xpPAxOnZgSMnrhDM5hYncOfWRGgaCUezvedY,1311 +matplotlib/backends/web_backend/js/mpl.js,sha256=YhJ_95_irTNrwlnDMRWGBebRb0DAhVBzj_96fL9Dsqs,24408 +matplotlib/backends/web_backend/js/mpl_tornado.js,sha256=Zs2Uzs7YUilG765nYvanCo-IK8HkHDtIum1KAq6bQ_w,302 +matplotlib/backends/web_backend/js/nbagg_mpl.js,sha256=F-By4ZjOSmwpNAkxUUxUk35qCjGlf0B28Z0aOyfpxDM,9514 +matplotlib/backends/web_backend/single_figure.html,sha256=wEBwF602JLHErBUEXiS6jXqmxYAIzHpa3MMFrnev6rs,1357 +matplotlib/bezier.py,sha256=ahJ1BRJoTbcxJoNmF1mCYc96mWs5jSM4DOK58jYaXkU,19049 +matplotlib/bezier.pyi,sha256=itubQt_U1IKnQ508bhy6J8LSpaspvZIeOmJqg7JvOmU,2586 +matplotlib/category.py,sha256=UsZ8rbADAH96YfpjD-N1DLTcS_cQwlszIIQ2ZRaiWm4,7377 +matplotlib/cbook.py,sha256=HAr8_jGa7m2Dg87TIiKqrVC-IrtbLPkiCK3AMPBOPLw,80161 +matplotlib/cbook.pyi,sha256=YkPZz_bsTYQJQ_wKx-qbsJYLhy2t2nCyvLNjJyiRtpw,6037 +matplotlib/cm.py,sha256=5xav-BvJ4pWSVdvGql4oYc4EpA-R6OuUP5xjrZCMKvg,10350 +matplotlib/cm.pyi,sha256=Ot5j2zDHYTra4kKuTwxYp7X5TsNwg_cJuYa7BxmQz-A,939 +matplotlib/collections.py,sha256=5AcX3lTsKD1ObSOkLr-irzYPfdJxsNrgw5dAD886vCE,96311 +matplotlib/collections.pyi,sha256=uZ4fPdq34oghdEtS1UkY7P0oOE2oPUzvS0Cr09yktaQ,10775 +matplotlib/colorbar.py,sha256=LVhceZdbo6km3N6d8gyJGDnpmE2xDgsPLo3CE7PzgCQ,60620 +matplotlib/colorbar.pyi,sha256=C1NzjMWFKm01rPUUr15Yjz1WB0pGakJQeatJO7G0mNs,4966 +matplotlib/colorizer.py,sha256=RzE0zjqB6elpketKidy6n-RRDbYF98speEIOIMfIFb4,25180 +matplotlib/colorizer.pyi,sha256=B_5-rEvNo4DblSifLodf3OVVEc3bTGsIYqqaQdU_4Tw,3308 +matplotlib/colors.py,sha256=h1YV59y4WvstOPC0x8eZzwHYItal7VjjVcyVS3niaSM,137303 +matplotlib/colors.pyi,sha256=XhCi3MtgSozOY5FATtPUHyKO4xMrvi7TvbCAl714Uxc,14908 +matplotlib/container.py,sha256=Y6v4j79gMk8QDYfrdOqbbJHH0BoOIO9enz4dtlaBSJU,4565 +matplotlib/container.pyi,sha256=DdthHVj1-bQQV3VcpMD1uIPLXVUKyKhWMXy8yCniK1I,1805 +matplotlib/contour.py,sha256=wpMtrMZEiDYPRuFbZ592HvQqn4NOBySfM17detKoenY,68391 +matplotlib/contour.pyi,sha256=X4DSH0u1YFUvKxuoAv8YbmVOJTJ-wbHig4lznzhsI0U,5300 +matplotlib/dates.py,sha256=xfcFY3g3AsW9isnEIKFGj4gXq0Xeoxox_3fxNCDQ01c,66306 +matplotlib/dviread.py,sha256=BqQg0fqXD_TmABJPSWDC-H2azt7B53NfOE1SC6mbXiI,42590 +matplotlib/dviread.pyi,sha256=5fps3GiPf1ibKY1TH6__kNEiJ9xhpVgzgait6jo09Hc,2139 +matplotlib/figure.py,sha256=CNPMl0QCxhr3h3WGcd271pE4Xtv4rapRWuUSFSNJSuc,141862 +matplotlib/figure.pyi,sha256=HWw1H_xICfG748BpSP32SVgF_mATqEzOdDZ87aY8X-A,14738 +matplotlib/font_manager.py,sha256=tSpOlZqiNUdIn3gQCFN_-nOLZ08WXBqj_unP6o1JyIw,57651 +matplotlib/font_manager.pyi,sha256=Jse1IshOdjhXHdjcvTxtos3rgsuECbRJzc264A-ZGus,5052 +matplotlib/ft2font.cpython-312-x86_64-linux-gnu.so,sha256=hCc0ptiMFCq0u9TgobTJHCnfP-b5sVd2C_fWnuvep48,1422872 +matplotlib/ft2font.pyi,sha256=yuDEfdw-7VyFuAHts-sMRm4VKV7IxYresBxEQgNOaPM,9253 +matplotlib/gridspec.py,sha256=s6RkXEk4ORWsMHjXkK98ZvosZ5D-qgenVNk0hIvMwOw,29786 +matplotlib/gridspec.pyi,sha256=3IQi_Q5KdwyPT_J_FWcngJ7iUOOCH4PSm42USm16Ofg,5099 +matplotlib/hatch.py,sha256=tfEM0DQxyLkUE87lHduMNlE55GKINtAZo4Y_YsAmSJo,7453 +matplotlib/hatch.pyi,sha256=OEFkNe8TgrBli_MDMW99Tvhr44S4ySPaYQomjVXpUUA,2098 +matplotlib/image.py,sha256=MLFg06J2xllm1tmgDnlLYw8gTy7__NGnkf4SZP-3mSs,69765 +matplotlib/image.pyi,sha256=R88aGGu6wP0lFKRudaM7iNxGgq4kh9uX9QRaZSorTLQ,7066 +matplotlib/inset.py,sha256=fJV8VMMY07_97iJR3_RO892WAKE7myfV_Gqg-qK99vg,10154 +matplotlib/inset.pyi,sha256=c-IVW14bpqb3qu7bz0EabvNXJnZwJAXEXoQoIcrL8ns,968 +matplotlib/layout_engine.py,sha256=mwRNh13mIQ-wpjQVgBaB_LWKN1AiQTWldg-X9nLmVok,11433 +matplotlib/layout_engine.pyi,sha256=9cKFTFPCvwmKIygTUvrvOq1iWnFYme9Shniiv2QbC74,1788 +matplotlib/legend.py,sha256=sCnDN8KNo6mYcYqhdQynNUStFvrWbMMGtv_rxgua5Qc,55311 +matplotlib/legend.pyi,sha256=hn-MNF3SPHtSUqIhwVXTebU_Nzk_wIh5iKgf7AEAZRg,5364 +matplotlib/legend_handler.py,sha256=lW8sQxdFW4-Bup0JUZvlpiIrAvczeNm6ih5BlCrdQnM,29931 +matplotlib/legend_handler.pyi,sha256=3VEfeioGIAxhd3mhg4PXETZjCKf4OlXL0jz1MAFGtos,7655 +matplotlib/lines.py,sha256=BG3xhTuzYDFjzf-k5CcQc_V7W4HlQl4CL7RCQQz0EC8,57920 +matplotlib/lines.pyi,sha256=3tG7tD8GZ8YPphw4nmkgS9SfdLV8icW36bv61jb9RlU,6081 +matplotlib/markers.py,sha256=g6ukerZ5n_sD_Enk0eJA4kkTvOOp8AfSGBq60PHot-E,33708 +matplotlib/markers.pyi,sha256=FFFBsvilnbd8-5L04U70kfVoyBwc18w_fZ6DysTj9p0,1678 +matplotlib/mathtext.py,sha256=yTlpUfnKxzrFl7XNjyuC-xMBBnxUufU6GVwQyhQAI2o,5104 +matplotlib/mathtext.pyi,sha256=RCVxYGQ_CJ6wC7v_HkqoouU2PhtcvlJ1ffIyxzAC-so,1045 +matplotlib/mlab.py,sha256=T71p4x0KJNVGrwUuoDdgcjWQucIQTuVrocOC98Zdwsc,30210 +matplotlib/mlab.pyi,sha256=mkR7wbJS9eCQfCFsUWoXnqrAy0kcE3cVqpFZCeOGC_Q,3583 +matplotlib/mpl-data/fonts/afm/cmex10.afm,sha256=blR3ERmrVBV5XKkAnDCj4NMeYVgzH7cXtJ3u59u9GuE,12070 +matplotlib/mpl-data/fonts/afm/cmmi10.afm,sha256=5qwEOpedEo76bDUahyuuF1q0cD84tRrX-VQ4p3MlfBo,10416 +matplotlib/mpl-data/fonts/afm/cmr10.afm,sha256=WDvgC_D3UkGJg9u-J0U6RaT02lF4oz3lQxHtg1r3lYw,10101 +matplotlib/mpl-data/fonts/afm/cmsy10.afm,sha256=AbmzvCVWBceHRfmRfeJ9E6xzOQTFLk0U1zDfpf3_MaM,8295 +matplotlib/mpl-data/fonts/afm/cmtt10.afm,sha256=4ji7_mTpeWMa93o_UHBWPKCnqsBfhJJNllat1lJArP4,6501 +matplotlib/mpl-data/fonts/afm/pagd8a.afm,sha256=jjFrigwkTpYLqa26cpzZvKQNBo-PuF4bmDVqaM4pMWw,17183 +matplotlib/mpl-data/fonts/afm/pagdo8a.afm,sha256=sgNQdeYyx8J-itGw9h31y95aMBiTCRvmNSPTXwwS7xg,17255 +matplotlib/mpl-data/fonts/afm/pagk8a.afm,sha256=ZUtfHPloNqcvGMHMxaKDSlshhOcjwheUx143RwpGdIU,17241 +matplotlib/mpl-data/fonts/afm/pagko8a.afm,sha256=Yj1wBg6Jsqqz1KBfhRoJ3ACR-CMQol8Fj_ZM5NZ1gDk,17346 +matplotlib/mpl-data/fonts/afm/pbkd8a.afm,sha256=Zl5o6J_di9Y5j2EpHtjew-_sfg7-WoeVmO9PzOYSTUc,15157 +matplotlib/mpl-data/fonts/afm/pbkdi8a.afm,sha256=JAOno930iTyfZILMf11vWtiaTgrJcPpP6FRTRhEMMD4,15278 +matplotlib/mpl-data/fonts/afm/pbkl8a.afm,sha256=UJqJjOJ6xQDgDBLX157mKpohIJFVmHM-N6x2-DiGv14,15000 +matplotlib/mpl-data/fonts/afm/pbkli8a.afm,sha256=AWislZ2hDbs0ox_qOWREugsbS8_8lpL48LPMR40qpi0,15181 +matplotlib/mpl-data/fonts/afm/pcrb8a.afm,sha256=6j1TS2Uc7DWSc-8l42TGDc1u0Fg8JspeWfxFayjUwi8,15352 +matplotlib/mpl-data/fonts/afm/pcrbo8a.afm,sha256=smg3mjl9QaBDtQIt06ko5GvaxLsO9QtTvYANuE5hfG0,15422 +matplotlib/mpl-data/fonts/afm/pcrr8a.afm,sha256=7nxFr0Ehz4E5KG_zSE5SZOhxRH8MyfnCbw-7x5wu7tw,15339 +matplotlib/mpl-data/fonts/afm/pcrro8a.afm,sha256=NKEz7XtdFkh9cA8MvY-S3UOZlV2Y_J3tMEWFFxj7QSg,15443 +matplotlib/mpl-data/fonts/afm/phvb8a.afm,sha256=NAx4M4HjL7vANCJbc-tk04Vkol-T0oaXeQ3T2h-XUvM,17155 +matplotlib/mpl-data/fonts/afm/phvb8an.afm,sha256=8e_myD-AQkNF7q9XNLb2m76_lX2TUr3a5wog_LIE1sk,17086 +matplotlib/mpl-data/fonts/afm/phvbo8a.afm,sha256=8fkBRmJ-SWY2YrBg8fFyjJyrJp8daQ6JPO6LvhM8xPI,17230 +matplotlib/mpl-data/fonts/afm/phvbo8an.afm,sha256=aeVRvV4r15BBvxuRJ0MG8ZHuH2HViuIiCYkvuapmkmM,17195 +matplotlib/mpl-data/fonts/afm/phvl8a.afm,sha256=IyMYM-bgl-gI6rG0EuZZ2OLzlxJfGeSh8xqsh0t-eJQ,15627 +matplotlib/mpl-data/fonts/afm/phvlo8a.afm,sha256=s12C-eNnIDHJ_UVbuiprjxBjCiHIbS3Y8ORTC-qTpuI,15729 +matplotlib/mpl-data/fonts/afm/phvr8a.afm,sha256=Kt8KaRidts89EBIK29X2JomDUEDxvroeaJz_RNTi6r4,17839 +matplotlib/mpl-data/fonts/afm/phvr8an.afm,sha256=lL5fAHTRwODl-sB5mH7IfsD1tnnea4yRUK-_Ca2bQHM,17781 +matplotlib/mpl-data/fonts/afm/phvro8a.afm,sha256=3KqK3eejiR4hIFBUynuSX_4lMdE2V2T58xOF8lX-fwc,17919 +matplotlib/mpl-data/fonts/afm/phvro8an.afm,sha256=Vx9rRf3YfasMY7tz-njSxz67xHKk-fNkN7yBi0X2IP0,17877 +matplotlib/mpl-data/fonts/afm/pncb8a.afm,sha256=aoXepTcDQtQa_mspflMJkEFKefzXHoyjz6ioJVI0YNc,16028 +matplotlib/mpl-data/fonts/afm/pncbi8a.afm,sha256=pCWW1MYgy0EmvwaYsaYJaAI_LfrsKmDANHu7Pk0RaiU,17496 +matplotlib/mpl-data/fonts/afm/pncr8a.afm,sha256=0CIB2BLe9r-6_Wl5ObRTTf98UOrezmGQ8ZOuBX5kLks,16665 +matplotlib/mpl-data/fonts/afm/pncri8a.afm,sha256=5R-pLZOnaHNG8pjV6MP3Ai-d2OTQYR_cYCb5zQhzfSU,16920 +matplotlib/mpl-data/fonts/afm/pplb8a.afm,sha256=3EzUbNnXr5Ft5eFLY00W9oWu59rHORgDXUuJaOoKN58,15662 +matplotlib/mpl-data/fonts/afm/pplbi8a.afm,sha256=X_9tVspvrcMer3OS8qvdwjFFqpAXYZneyCL2NHA902g,15810 +matplotlib/mpl-data/fonts/afm/pplr8a.afm,sha256=ijMb497FDJ9nVdVMb21F7W3-cu9sb_9nF0oriFpSn8k,15752 +matplotlib/mpl-data/fonts/afm/pplri8a.afm,sha256=8KITbarcUUMi_hdoRLLmNHtlqs0TtOSKqtPFft7X5nY,15733 +matplotlib/mpl-data/fonts/afm/psyr.afm,sha256=Iyt8ajE4B2Tm34oBj2pKtctIf9kPfq05suQefq8p3Ro,9644 +matplotlib/mpl-data/fonts/afm/ptmb8a.afm,sha256=bL1fA1NC4_nW14Zrnxz4nHlXJb4dzELJPvodqKnYeMg,17983 +matplotlib/mpl-data/fonts/afm/ptmbi8a.afm,sha256=-_Ui6XlKaFTHEnkoS_-1GtIr5VtGa3gFQ2ezLOYHs08,18070 +matplotlib/mpl-data/fonts/afm/ptmr8a.afm,sha256=IEcsWcmzJyjCwkgsw4o6hIMmzlyXUglJat9s1PZNnEU,17942 +matplotlib/mpl-data/fonts/afm/ptmri8a.afm,sha256=49fQMg5fIGguZ7rgc_2styMK55Pv5bPTs7wCzqpcGpk,18068 +matplotlib/mpl-data/fonts/afm/putb8a.afm,sha256=qMaHTdpkrNL-m4DWhjpxJCSmgYkCv1qIzLlFfM0rl40,21532 +matplotlib/mpl-data/fonts/afm/putbi8a.afm,sha256=g7AVJyiTxeMpNk_1cSfmYgM09uNUfPlZyWGv3D1vcAk,21931 +matplotlib/mpl-data/fonts/afm/putr8a.afm,sha256=XYmNC5GQgSVAZKTIYdYeNksE6znNm9GF_0SmQlriqx0,22148 +matplotlib/mpl-data/fonts/afm/putri8a.afm,sha256=i7fVe-iLyLtQxCfAa4IxdxH-ufcHmMk7hbCGG5TxAY4,21891 +matplotlib/mpl-data/fonts/afm/pzcmi8a.afm,sha256=wyuoIWEZOcoXrSl1tPzLkEahik7kGi91JJj-tkFRG4A,16250 +matplotlib/mpl-data/fonts/afm/pzdr.afm,sha256=MyjLAnzKYRdQBfof1W3k_hf30MvqOkqL__G22mQ5xww,9467 +matplotlib/mpl-data/fonts/pdfcorefonts/Courier-Bold.afm,sha256=sIDDI-B82VZ3C0mI_mHFITCZ7PVn37AIYMv1CrHX4sE,15333 +matplotlib/mpl-data/fonts/pdfcorefonts/Courier-BoldOblique.afm,sha256=zg61QobD3YU9UBfCXmvmhBNaFKno-xj8sY0b2RpgfLw,15399 +matplotlib/mpl-data/fonts/pdfcorefonts/Courier-Oblique.afm,sha256=vRQm5j1sTUN4hicT1PcVZ9P9DTTUHhEzfPXqUUzVZhE,15441 +matplotlib/mpl-data/fonts/pdfcorefonts/Courier.afm,sha256=Mdcq2teZEBJrIqVXnsnhee7oZnTs6-P8_292kWGTrw4,15335 +matplotlib/mpl-data/fonts/pdfcorefonts/Helvetica-Bold.afm,sha256=i2l4gcjuYXoXf28uK7yIVwuf0rnw6J7PwPVQeHj5iPw,69269 +matplotlib/mpl-data/fonts/pdfcorefonts/Helvetica-BoldOblique.afm,sha256=Um5O6qK11DXLt8uj_0IoWkc84TKqHK3bObSKUswQqvY,69365 +matplotlib/mpl-data/fonts/pdfcorefonts/Helvetica-Oblique.afm,sha256=hVYDg2b52kqtbVeCzmiv25bW1yYdpkZS-LXlGREN2Rs,74392 +matplotlib/mpl-data/fonts/pdfcorefonts/Helvetica.afm,sha256=23cvKDD7bQAJB3kdjSahJSTZaUOppznlIO6FXGslyW8,74292 +matplotlib/mpl-data/fonts/pdfcorefonts/Symbol.afm,sha256=P5UaoXr4y0qh4SiMa5uqijDT6ZDr2-jPmj1ayry593E,9740 +matplotlib/mpl-data/fonts/pdfcorefonts/Times-Bold.afm,sha256=cQTmr2LFPwKQE_sGQageMcmFicjye16mKJslsJLHQyE,64251 +matplotlib/mpl-data/fonts/pdfcorefonts/Times-BoldItalic.afm,sha256=pzWOdycm6RqocBWgAVY5Jq0z3Fp7LuqWgLNMx4q6OFw,59642 +matplotlib/mpl-data/fonts/pdfcorefonts/Times-Italic.afm,sha256=bK5puSMpGT_YUILwyJrXoxjfj7XJOdfv5TQ_iKsJRzw,66328 +matplotlib/mpl-data/fonts/pdfcorefonts/Times-Roman.afm,sha256=hhNrUnpazuDDKD1WpraPxqPWCYLrO7D7bMVOg-zI13o,60460 +matplotlib/mpl-data/fonts/pdfcorefonts/ZapfDingbats.afm,sha256=ZuOmt9GcKofjdOq8kqhPhtAIhOwkL2rTJTmZxAjFakA,9527 +matplotlib/mpl-data/fonts/pdfcorefonts/readme.txt,sha256=MRv8ppSITYYAb7lt5EOw9DWWNZIblfxsFhu5TQE7cpI,828 +matplotlib/mpl-data/fonts/ttf/DejaVuSans-Bold.ttf,sha256=sYS4njwQdfIva3FXW2_CDUlys8_TsjMiym_Vltyu8Wc,704128 +matplotlib/mpl-data/fonts/ttf/DejaVuSans-BoldOblique.ttf,sha256=bt8CgxYBhq9FHL7nHnuEXy5Mq_Jku5ks5mjIPCVGXm8,641720 +matplotlib/mpl-data/fonts/ttf/DejaVuSans-Oblique.ttf,sha256=zN90s1DxH9PdV3TeUOXmNGoaXaH1t9X7g1kGZel6UhM,633840 +matplotlib/mpl-data/fonts/ttf/DejaVuSans.ttf,sha256=P99pyr8GBJ6nCgC1kZNA4s4ebQKwzDxLRPtoAb0eDSI,756072 +matplotlib/mpl-data/fonts/ttf/DejaVuSansDisplay.ttf,sha256=ggmdz7paqGjN_CdFGYlSX-MpL3N_s8ngMozpzvWWUvY,25712 +matplotlib/mpl-data/fonts/ttf/DejaVuSansMono-Bold.ttf,sha256=uq2ppRcv4giGJRr_BDP8OEYZEtXa8HKH577lZiCo2pY,331536 +matplotlib/mpl-data/fonts/ttf/DejaVuSansMono-BoldOblique.ttf,sha256=ppCBwVx2yCfgonpaf1x0thNchDSZlVSV_6jCDTqYKIs,253116 +matplotlib/mpl-data/fonts/ttf/DejaVuSansMono-Oblique.ttf,sha256=KAUoE_enCfyJ9S0ZLcmV708P3Fw9e3OknWhJsZFtDNA,251472 +matplotlib/mpl-data/fonts/ttf/DejaVuSansMono.ttf,sha256=YC7Ia4lIz82VZIL-ZPlMNshndwFJ7y95HUYT9EO87LM,340240 +matplotlib/mpl-data/fonts/ttf/DejaVuSerif-Bold.ttf,sha256=w3U_Lta8Zz8VhG3EWt2-s7nIcvMvsY_VOiHxvvHtdnY,355692 +matplotlib/mpl-data/fonts/ttf/DejaVuSerif-BoldItalic.ttf,sha256=2T7-x6nS6CZ2jRou6VuVhw4V4pWZqE80hK8d4c7C4YE,347064 +matplotlib/mpl-data/fonts/ttf/DejaVuSerif-Italic.ttf,sha256=PnmU-8VPoQzjNSpC1Uj63X2crbacsRCbydlg9trFfwQ,345612 +matplotlib/mpl-data/fonts/ttf/DejaVuSerif.ttf,sha256=EHJElW6ZYrnpb6zNxVGCXgrgiYrhNzcTPhuSGi_TX_o,379740 +matplotlib/mpl-data/fonts/ttf/DejaVuSerifDisplay.ttf,sha256=KRTzLkfHd8J75Wd6-ufbTeefnkXeb8kJfZlJwjwU99U,14300 +matplotlib/mpl-data/fonts/ttf/LICENSE_DEJAVU,sha256=11k43sCY8G8Kw8AIUwZdlPAgvhw8Yu8dwpdboVtNmw4,4816 +matplotlib/mpl-data/fonts/ttf/LICENSE_STIX,sha256=urPTHf7wf0g2JPL2XycR52BluOcnMnixwHHt4QQcmVk,5476 +matplotlib/mpl-data/fonts/ttf/STIXGeneral.ttf,sha256=FnN4Ax4t3cYhbWeBnJJg6aBv_ExHjk4jy5im_USxg8I,448228 +matplotlib/mpl-data/fonts/ttf/STIXGeneralBol.ttf,sha256=6FM9xwg_o0a9oZM9YOpKg7Z9CUW86vGzVB-CtKDixqA,237360 +matplotlib/mpl-data/fonts/ttf/STIXGeneralBolIta.ttf,sha256=mHiP1LpI37sr0CbA4gokeosGxzcoeWKLemuw1bsJc2w,181152 +matplotlib/mpl-data/fonts/ttf/STIXGeneralItalic.ttf,sha256=bPyzM9IrfDxiO9_UAXTxTIXD1nMcphZsHtyAFA6uhSc,175040 +matplotlib/mpl-data/fonts/ttf/STIXNonUni.ttf,sha256=Ulb34CEzWsSFTRgPDovxmJZOwvyCAXYnbhaqvGU3u1c,59108 +matplotlib/mpl-data/fonts/ttf/STIXNonUniBol.ttf,sha256=XRBqW3jR_8MBdFU0ObhiV7-kXwiBIMs7QVClHcT5tgs,30512 +matplotlib/mpl-data/fonts/ttf/STIXNonUniBolIta.ttf,sha256=pb22DnbDf2yQqizotc3wBDqFGC_g27YcCGJivH9-Le8,41272 +matplotlib/mpl-data/fonts/ttf/STIXNonUniIta.ttf,sha256=BMr9pWiBv2YIZdq04X4c3CgL6NPLUPrl64aV1N4w9Ug,46752 +matplotlib/mpl-data/fonts/ttf/STIXSizFiveSymReg.ttf,sha256=wYuH1gYUpCuusqItRH5kf9p_s6mUD-9X3L5RvRtKSxs,13656 +matplotlib/mpl-data/fonts/ttf/STIXSizFourSymBol.ttf,sha256=yNdvjUoSmsZCULmD7SVq9HabndG9P4dPhboL1JpAf0s,12228 +matplotlib/mpl-data/fonts/ttf/STIXSizFourSymReg.ttf,sha256=-9xVMYL4_1rcO8FiCKrCfR4PaSmKtA42ddLGqwtei1w,15972 +matplotlib/mpl-data/fonts/ttf/STIXSizOneSymBol.ttf,sha256=cYexyo8rZcdqMlpa9fNF5a2IoXLUTZuIvh0JD1Qp0i4,12556 +matplotlib/mpl-data/fonts/ttf/STIXSizOneSymReg.ttf,sha256=0lbHzpndzJmO8S42mlkhsz5NbvJLQCaH5Mcc7QZRDzc,19760 +matplotlib/mpl-data/fonts/ttf/STIXSizThreeSymBol.ttf,sha256=3eBc-VtYbhQU3BnxiypfO6eAzEu8BdDvtIJSFbkS2oY,12192 +matplotlib/mpl-data/fonts/ttf/STIXSizThreeSymReg.ttf,sha256=XFSKCptbESM8uxHtUFSAV2cybwxhSjd8dWVByq6f3w0,15836 +matplotlib/mpl-data/fonts/ttf/STIXSizTwoSymBol.ttf,sha256=MUCYHrA0ZqFiSE_PjIGlJZgMuv79aUgQqE7Dtu3kuo0,12116 +matplotlib/mpl-data/fonts/ttf/STIXSizTwoSymReg.ttf,sha256=_sdxDuEwBDtADpu9CyIXQxV7sIqA2TZVBCUiUjq5UCk,15704 +matplotlib/mpl-data/fonts/ttf/cmb10.ttf,sha256=B0SXtQxD6ldZcYFZH5iT04_BKofpUQT1ZX_CSB9hojo,25680 +matplotlib/mpl-data/fonts/ttf/cmex10.ttf,sha256=ryjwwXByOsd2pxv6WVrKCemNFa5cPVTOGa_VYZyWqQU,21092 +matplotlib/mpl-data/fonts/ttf/cmmi10.ttf,sha256=MJKWW4gR_WpnZXmWZIRRgfwd0TMLk3-RWAjEhdMWI00,32560 +matplotlib/mpl-data/fonts/ttf/cmr10.ttf,sha256=Tdl2GwWMAJ25shRfVe5mF9CTwnPdPWxbPkP_YRD6m_Y,26348 +matplotlib/mpl-data/fonts/ttf/cmss10.ttf,sha256=ffkag9BbLkcexjjLC0NaNgo8eSsJ_EKn2mfpHy55EVo,20376 +matplotlib/mpl-data/fonts/ttf/cmsy10.ttf,sha256=uyJu2TLz8QDNDlL15JEu5VO0G2nnv9uNOFTbDrZgUjI,29396 +matplotlib/mpl-data/fonts/ttf/cmtt10.ttf,sha256=YhHwmuk1mZka_alwwkZp2tGnfiU9kVYk-_IS9wLwcdc,28136 +matplotlib/mpl-data/images/back-symbolic.svg,sha256=Okj_ressZkfe6Ewv_o7GF5toc5qWCeFkQ2cHQ25BdVE,1532 +matplotlib/mpl-data/images/back.pdf,sha256=ZR7CJo_dAeCM-KlaGvskgtHQyRtrPIolc8REOmcoqJk,1623 +matplotlib/mpl-data/images/back.png,sha256=E4dGf4Gnz1xJ1v2tMygHV0YNQgShreDeVApaMb-74mU,380 +matplotlib/mpl-data/images/back.svg,sha256=Okj_ressZkfe6Ewv_o7GF5toc5qWCeFkQ2cHQ25BdVE,1532 +matplotlib/mpl-data/images/back_large.png,sha256=9A6hUSQeszhYONE4ZuH3kvOItM0JfDVu6tkfromCbsQ,620 +matplotlib/mpl-data/images/filesave-symbolic.svg,sha256=dMGXvLSOHPu44kiWgZx-B_My_tLWaP6J6GgxJfL4FW0,2049 +matplotlib/mpl-data/images/filesave.pdf,sha256=P1EPPV2g50WTt8UaX-6kFoTZM1xVqo6S2H6FJ6Zd1ec,1734 +matplotlib/mpl-data/images/filesave.png,sha256=b7ctucrM_F2mG-DycTedG_a_y4pHkx3F-zM7l18GLhk,458 +matplotlib/mpl-data/images/filesave.svg,sha256=dMGXvLSOHPu44kiWgZx-B_My_tLWaP6J6GgxJfL4FW0,2049 +matplotlib/mpl-data/images/filesave_large.png,sha256=LNbRD5KZ3Kf7nbp-stx_a1_6XfGBSWUfDdpgmnzoRvk,720 +matplotlib/mpl-data/images/forward-symbolic.svg,sha256=kOiKq3a4mieMRLVCwQBdOMTRrWG2NOX_5-rbAFHpdmQ,1551 +matplotlib/mpl-data/images/forward.pdf,sha256=KIqIL4YId43LkcOxV_TT5uvz1SP8k5iUNUeJmAElMV8,1630 +matplotlib/mpl-data/images/forward.png,sha256=pKbLepgGiGeyY2TCBl8svjvm7Z4CS3iysFxcq4GR-wk,357 +matplotlib/mpl-data/images/forward.svg,sha256=kOiKq3a4mieMRLVCwQBdOMTRrWG2NOX_5-rbAFHpdmQ,1551 +matplotlib/mpl-data/images/forward_large.png,sha256=36h7m7DZDHql6kkdpNPckyi2LKCe_xhhyavWARz_2kQ,593 +matplotlib/mpl-data/images/hand.pdf,sha256=hspwkNY915KPD7AMWnVQs7LFPOtlcj0VUiLu76dMabQ,4172 +matplotlib/mpl-data/images/hand.png,sha256=2cchRETGKa0hYNKUxnJABwkyYXEBPqJy_VqSPlT0W2Q,979 +matplotlib/mpl-data/images/hand.svg,sha256=hxxBtakaVFA7mpZOGakvo0QUcb2x06rojeS5gnVmyuc,4906 +matplotlib/mpl-data/images/help-symbolic.svg,sha256=XVcFcuzcL3SQ3LjfSbtdLYDjoB5YUkj2jk2Gk8vaZF8,1890 +matplotlib/mpl-data/images/help.pdf,sha256=CeE978IMi0YWznWKjIT1R8IrP4KhZ0S7usPUvreSgcA,1813 +matplotlib/mpl-data/images/help.png,sha256=s4pQrqaQ0py8I7vc9hv3BI3DO_tky-7YBMpaHuBDCBY,472 +matplotlib/mpl-data/images/help.svg,sha256=XVcFcuzcL3SQ3LjfSbtdLYDjoB5YUkj2jk2Gk8vaZF8,1890 +matplotlib/mpl-data/images/help_large.png,sha256=1IwEyWfGRgnoCWM-r9CJHEogTJVD5n1c8LXTK4AJ4RE,747 +matplotlib/mpl-data/images/home-symbolic.svg,sha256=ptrus8h5PZTi9ahYfnaz-uZ8MAHCr72aPeMW48TBR9Q,1911 +matplotlib/mpl-data/images/home.pdf,sha256=e0e0pI-XRtPmvUCW2VTKL1DeYu1pvPmUUeRSgEbWmik,1737 +matplotlib/mpl-data/images/home.png,sha256=IcFdAAUa6_A0qt8IO3I8p4rpXpQgAlJ8ndBECCh7C1w,468 +matplotlib/mpl-data/images/home.svg,sha256=ptrus8h5PZTi9ahYfnaz-uZ8MAHCr72aPeMW48TBR9Q,1911 +matplotlib/mpl-data/images/home_large.png,sha256=uxS2O3tWOHh1iau7CaVV4ermIJaZ007ibm5Z3i8kXYg,790 +matplotlib/mpl-data/images/matplotlib.pdf,sha256=BkSUf-2xoij-eXfpV2t7y1JFKG1zD1gtV6aAg3Xi_wE,22852 +matplotlib/mpl-data/images/matplotlib.png,sha256=w8KLRYVa-voUZXa41hgJauQuoois23f3NFfdc72pUYY,1283 +matplotlib/mpl-data/images/matplotlib.svg,sha256=QiTIcqlQwGaVPtHsEk-vtmJk1wxwZSvijhqBe_b9VCI,62087 +matplotlib/mpl-data/images/matplotlib_large.png,sha256=ElRoue9grUqkZXJngk-nvh4GKfpvJ4gE69WryjCbX5U,3088 +matplotlib/mpl-data/images/move-symbolic.svg,sha256=_uamLnjQ20iwSuKbd8JvTXUFaRq4206MrpFWvtErr8I,2529 +matplotlib/mpl-data/images/move.pdf,sha256=CXk3PGK9WL5t-5J-G2X5Tl-nb6lcErTBS5oUj2St6aU,1867 +matplotlib/mpl-data/images/move.png,sha256=TmjR41IzSzxGbhiUcV64X0zx2BjrxbWH3cSKvnG2vzc,481 +matplotlib/mpl-data/images/move.svg,sha256=_uamLnjQ20iwSuKbd8JvTXUFaRq4206MrpFWvtErr8I,2529 +matplotlib/mpl-data/images/move_large.png,sha256=Skjz2nW_RTA5s_0g88gdq2hrVbm6DOcfYW4Fu42Fn9U,767 +matplotlib/mpl-data/images/qt4_editor_options.pdf,sha256=2qu6GVyBrJvVHxychQoJUiXPYxBylbH2j90QnytXs_w,1568 +matplotlib/mpl-data/images/qt4_editor_options.png,sha256=EryQjQ5hh2dwmIxtzCFiMN1U6Tnd11p1CDfgH5ZHjNM,380 +matplotlib/mpl-data/images/qt4_editor_options.svg,sha256=sdrNIxYT-BLvJ30ASnaRQ5PxF3SB41-pgdaIJT0KqBg,1264 +matplotlib/mpl-data/images/qt4_editor_options_large.png,sha256=-Pd-9Vh5aIr3PZa8O6Ge_BLo41kiEnpmkdDj8a11JkY,619 +matplotlib/mpl-data/images/subplots-symbolic.svg,sha256=Gq4fDSS99Rv5rbR8_nenV6jcY5VsKPARWeH-BZBk9CU,2150 +matplotlib/mpl-data/images/subplots.pdf,sha256=Q0syPMI5EvtgM-CE-YXKOkL9eFUAZnj_X2Ihoj6R4p4,1714 +matplotlib/mpl-data/images/subplots.png,sha256=MUfCItq3_yzb9yRieGOglpn0Y74h8IA7m5i70B63iRc,445 +matplotlib/mpl-data/images/subplots.svg,sha256=Gq4fDSS99Rv5rbR8_nenV6jcY5VsKPARWeH-BZBk9CU,2150 +matplotlib/mpl-data/images/subplots_large.png,sha256=Edu9SwVMQEXJZ5ogU5cyW7VLcwXJdhdf-EtxxmxdkIs,662 +matplotlib/mpl-data/images/zoom_to_rect-symbolic.svg,sha256=uMmdGkO43ZHlezkpieR3_MiqlEc5vROffRDOhY4sxm4,1499 +matplotlib/mpl-data/images/zoom_to_rect.pdf,sha256=SEvPc24gfZRpl-dHv7nx8KkxPyU66Kq4zgQTvGFm9KA,1609 +matplotlib/mpl-data/images/zoom_to_rect.png,sha256=aNz3QZBrIgxu9E-fFfaQweCVNitGuDUFoC27e5NU2L4,530 +matplotlib/mpl-data/images/zoom_to_rect.svg,sha256=uMmdGkO43ZHlezkpieR3_MiqlEc5vROffRDOhY4sxm4,1499 +matplotlib/mpl-data/images/zoom_to_rect_large.png,sha256=V6pkxmm6VwFExdg_PEJWdK37HB7k3cE_corLa7RbUMk,1016 +matplotlib/mpl-data/kpsewhich.lua,sha256=RdyYaBnBLy3NsB5c2R5FGrKu-V-WBcZim24NWilsTfw,139 +matplotlib/mpl-data/matplotlibrc,sha256=CXIWz2WeGv3kyoQsMBDOO0m3yF9M_pK1EihXl01w0bw,43413 +matplotlib/mpl-data/plot_directive/plot_directive.css,sha256=utSJ1oETz0UG6AC9hU134J_JY78ENijqMZXN0JMBUfk,318 +matplotlib/mpl-data/sample_data/Minduka_Present_Blue_Pack.png,sha256=XnKGiCanpDKalQ5anvo5NZSAeDP7fyflzQAaivuc0IE,13634 +matplotlib/mpl-data/sample_data/README.txt,sha256=ABz19VBKfGewdY39QInG9Qccgn1MTYV3bT5Ph7TCy2Y,128 +matplotlib/mpl-data/sample_data/Stocks.csv,sha256=72878aZNXGxd5wLvFUw_rnj-nfg4gqtrucZji-w830c,67924 +matplotlib/mpl-data/sample_data/axes_grid/bivariate_normal.npy,sha256=DpWZ9udAh6ospYqneEa27D6EkRgORFwHosacZXVu98U,1880 +matplotlib/mpl-data/sample_data/data_x_x2_x3.csv,sha256=A0SU3buOUGhT-NI_6LQ6p70fFSIU3iLFdgzvzrKR6SE,132 +matplotlib/mpl-data/sample_data/eeg.dat,sha256=KGVjFt8ABKz7p6XZirNfcxSTOpGGNuyA8JYErRKLRBc,25600 +matplotlib/mpl-data/sample_data/embedding_in_wx3.xrc,sha256=cUqVw5vDHNSZoaO4J0ebZUf5SrJP36775abs7R9Bclg,2186 +matplotlib/mpl-data/sample_data/goog.npz,sha256=QAkXzzDmtmT3sNqT18dFhg06qQCNqLfxYNLdEuajGLE,22845 +matplotlib/mpl-data/sample_data/grace_hopper.jpg,sha256=qMptc0dlcDsJcoq0f-WfRz2Trjln_CTHwCiMPHrbcTA,61306 +matplotlib/mpl-data/sample_data/jacksboro_fault_dem.npz,sha256=1JP1CjPoKkQgSUxU0fyhU50Xe9wnqxkLxf5ukvYvtjc,174061 +matplotlib/mpl-data/sample_data/logo2.png,sha256=DXNx4FXeyqxHy26AmvNELpwezQLxweLQY9HP7ktKIdc,22279 +matplotlib/mpl-data/sample_data/membrane.dat,sha256=q3lbQpIBpbtXXGNw1eFwkN_PwxdDGqk4L46IE2b0M1c,48000 +matplotlib/mpl-data/sample_data/msft.csv,sha256=GArKb0O3DgKZRsKdJf6lX3rMSf-PCekIiBoLNdgF7Mk,3211 +matplotlib/mpl-data/sample_data/s1045.ima.gz,sha256=MrQk1k9it-ccsk0p_VOTitVmTWCAVaZ6srKvQ2n4uJ4,33229 +matplotlib/mpl-data/sample_data/topobathy.npz,sha256=AkTgMpFwLfRQJNy1ysvE89TLMNct-n_TccSsYcQrT78,45224 +matplotlib/mpl-data/stylelib/Solarize_Light2.mplstyle,sha256=aytOm4eT_SPvs7HC28ZY4GukeN44q-SE0JEMCR8kVOk,1257 +matplotlib/mpl-data/stylelib/_classic_test_patch.mplstyle,sha256=iopHpMaM3im_AK2aiHGuM2DKM5i9Kc84v6NQEoSb10Q,167 +matplotlib/mpl-data/stylelib/_mpl-gallery-nogrid.mplstyle,sha256=1VOL3USqD6iuGQaSynNg1QhyUwvKLnkLyUKdbBMnnqg,489 +matplotlib/mpl-data/stylelib/_mpl-gallery.mplstyle,sha256=MN-q59CiDqHXB8xFKXxzCbJJbJmNDhBe9lDJJAoMTPA,504 +matplotlib/mpl-data/stylelib/bmh.mplstyle,sha256=-KbhaI859BITHIoyUZIfpQDjfckgLAlDAS_ydKsm6mc,712 +matplotlib/mpl-data/stylelib/classic.mplstyle,sha256=1o5b47VD_RIZv3unnG9Gm2tbprTvOeNGXM8hJCmGuYI,24670 +matplotlib/mpl-data/stylelib/dark_background.mplstyle,sha256=GzSBD06jvlRYOqu7D5Z5a5x25l9JnTgtObn7S4D9zug,607 +matplotlib/mpl-data/stylelib/fast.mplstyle,sha256=yTa2YEIIP9xi5V_G0p2vSlxghuhNwjRi9gPECMxyRiM,288 +matplotlib/mpl-data/stylelib/fivethirtyeight.mplstyle,sha256=IcBt2DSDz9CtuyCis9JZtT3Nqoh6LVO-dB66AxObUcA,780 +matplotlib/mpl-data/stylelib/ggplot.mplstyle,sha256=u2oPHMLWFtZcpIjHk2swi2Nrt4NgnEtof5lxcwM0RD0,956 +matplotlib/mpl-data/stylelib/grayscale.mplstyle,sha256=KCLg-pXpns9cnKDXKN2WH6mV41OH-6cbT-5zKQotSdw,526 +matplotlib/mpl-data/stylelib/petroff10.mplstyle,sha256=Qj7pPbHh3L25kdsPt1ypwvKR3dPzuOrFzzQTP3Mfilg,298 +matplotlib/mpl-data/stylelib/seaborn-v0_8-bright.mplstyle,sha256=pDqn3-NUyVLvlfkYs8n8HzNZvmslVMChkeH-HtZuJIc,144 +matplotlib/mpl-data/stylelib/seaborn-v0_8-colorblind.mplstyle,sha256=eCSzFj5_2vR6n5qu1rHE46wvSVGZcdVqz85ov40ZsH8,148 +matplotlib/mpl-data/stylelib/seaborn-v0_8-dark-palette.mplstyle,sha256=p5ABKNQHRG7bk4HXqMQrRBjDlxGAo3RCXHdQmP7g-Ng,142 +matplotlib/mpl-data/stylelib/seaborn-v0_8-dark.mplstyle,sha256=I4xQ75vE5_9X4k0cNDiqhhnF3OcrZ2xlPX8Ll7OCkoE,667 +matplotlib/mpl-data/stylelib/seaborn-v0_8-darkgrid.mplstyle,sha256=2bXOSzS5gmPzRBrRmzVWyhg_7ZaBRQ6t_-O-cRuyZoA,670 +matplotlib/mpl-data/stylelib/seaborn-v0_8-deep.mplstyle,sha256=44dLcXjjRgR-6yaopgGRInaVgz3jk8VJVQTbBIcxRB0,142 +matplotlib/mpl-data/stylelib/seaborn-v0_8-muted.mplstyle,sha256=T4o3jvqKD_ImXDkp66XFOV_xrBVFUolJU34JDFk1Xkk,143 +matplotlib/mpl-data/stylelib/seaborn-v0_8-notebook.mplstyle,sha256=PcvZQbYrDdducrNlavBPmQ1g2minio_9GkUUFRdgtoM,382 +matplotlib/mpl-data/stylelib/seaborn-v0_8-paper.mplstyle,sha256=n0mboUp2C4Usq2j6tNWcu4TZ_YT4-kKgrYO0t-rz1yw,393 +matplotlib/mpl-data/stylelib/seaborn-v0_8-pastel.mplstyle,sha256=8nV8qRpbUrnFZeyE6VcQ1oRuZPLil2W74M2U37DNMOE,144 +matplotlib/mpl-data/stylelib/seaborn-v0_8-poster.mplstyle,sha256=dUaKqTE4MRfUq2rWVXbbou7kzD7Z9PE9Ko8aXLza8JA,403 +matplotlib/mpl-data/stylelib/seaborn-v0_8-talk.mplstyle,sha256=7FnBaBEdWBbncTm6_ER-EQVa_bZgU7dncgez-ez8R74,403 +matplotlib/mpl-data/stylelib/seaborn-v0_8-ticks.mplstyle,sha256=CITZmZFUFp40MK2Oz8tI8a7WRoCizQU9Z4J172YWfWw,665 +matplotlib/mpl-data/stylelib/seaborn-v0_8-white.mplstyle,sha256=WjJ6LEU6rlCwUugToawciAbKP9oERFHr9rfFlUrdTx0,665 +matplotlib/mpl-data/stylelib/seaborn-v0_8-whitegrid.mplstyle,sha256=ec4BjsNzmOvHptcJ3mdPxULF3S1_U1EUocuqfIpw-Nk,664 +matplotlib/mpl-data/stylelib/seaborn-v0_8.mplstyle,sha256=_Xu6qXKzi4b3GymCOB1b1-ykKTQ8xhDliZ8ezHGTiAs,1130 +matplotlib/mpl-data/stylelib/tableau-colorblind10.mplstyle,sha256=BsirZVd1LmPWT4tBIz6loZPjZcInoQrIGfC7rvzqmJw,190 +matplotlib/offsetbox.py,sha256=WGa1TdIGkg1LPPL6ZJ8dbE8wfumJMz863sjsafPm5Vg,54488 +matplotlib/offsetbox.pyi,sha256=ISf7gRrOhvefNKfutbHIPh6QYS0kAgtA97bYa6N-82o,9909 +matplotlib/patches.py,sha256=kI0XgcYLusGwILt147Hmoh4NhoR6unlZdafgQrZxZJY,165054 +matplotlib/patches.pyi,sha256=d-sCqVXw_ANsvoUpliH7dllMbTy7TcyRm2IV97Ll0nM,22649 +matplotlib/path.py,sha256=CD1LeGQMm9p0U5mSRIG1L2WCiIie3rYhykLwud1qLA8,42717 +matplotlib/path.pyi,sha256=N1XOCWSUG4RtCcMo02yvvq3HDYo_369oKQkEJUHN80c,4777 +matplotlib/patheffects.py,sha256=4paQgPGKc_xdBz_BZ_MaVhKJ2FxZDGeh5zHwzn3evDY,18387 +matplotlib/patheffects.pyi,sha256=7-FhuxGGrer94GtJ1sZ0YxOmK6Nv4oixTmsKb6-ijOg,3664 +matplotlib/projections/__init__.py,sha256=gICKgNfjJ-tioWEFa75BKKJZG6JJjx46_RpEqt5fk94,4438 +matplotlib/projections/__init__.pyi,sha256=D28dSYmwZcSBFBtNDer-QqE_lqXAhHKersuAlvi89jE,673 +matplotlib/projections/__pycache__/__init__.cpython-312.pyc,, +matplotlib/projections/__pycache__/geo.cpython-312.pyc,, +matplotlib/projections/__pycache__/polar.cpython-312.pyc,, +matplotlib/projections/geo.py,sha256=feR2dybCylhk6xe-MzxBr_e8MdbpVc8tBtCQQJ0V2co,17605 +matplotlib/projections/geo.pyi,sha256=vPfhvj7_e0ZnKjyfDUNC89RGCktycJBPnn5D8w0A7N8,3775 +matplotlib/projections/polar.py,sha256=f7-1dEIXWkh2_NOoVf8pcVPe0PqfkrKL0V1-ykElM_s,56939 +matplotlib/projections/polar.pyi,sha256=IWNSLQIo5cXFBJur5VFVDQBwAtq5n1XFZMnrj85mCBg,6636 +matplotlib/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +matplotlib/pylab.py,sha256=VqUqd2J2-dKtltZtsYP8ePKoX1jsNDpTyqlcmpfC-lo,2369 +matplotlib/pyplot.py,sha256=Dbp8GKxM6B5oCmcjb8lsik2R8QPtPt3JgmgaqeWqkPw,149704 +matplotlib/quiver.py,sha256=L8O6Ke6oH60R9VA_gd9JiA5Fvv5sMPIFyC_uNRrZSo0,48675 +matplotlib/quiver.pyi,sha256=Kq1FvQP-DRX1N9Xlp6XF6OJfKkJ7tc4dYq4CEndxEzg,5640 +matplotlib/rcsetup.py,sha256=87KrQeDFg4sRu4obxF4mc0eGSCuClGyuzXsxoZ_kme4,51606 +matplotlib/rcsetup.pyi,sha256=0VTIhzfKgBcKxOeOdoKq1oa7ZubalOj9Ks0efE0-cms,4337 +matplotlib/sankey.py,sha256=eKA9DrX96-bpoWRdeNnvC73aJfPtCnB78WmMPCXQ9rc,36151 +matplotlib/sankey.pyi,sha256=P2AGkhdg3ZLqXaQdf8yodblrBpdgEQZBLKPL1mbxmFc,1451 +matplotlib/scale.py,sha256=n3JqLU_d3dAKrP1uO5lTipVEaxgiLhDR2sSk8TAXE_M,26924 +matplotlib/scale.pyi,sha256=-ptRptcqiAuzfKwrjgSWWOxFmjRUTOKGwIoWtuBXKgY,5057 +matplotlib/sphinxext/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +matplotlib/sphinxext/__pycache__/__init__.cpython-312.pyc,, +matplotlib/sphinxext/__pycache__/figmpl_directive.cpython-312.pyc,, +matplotlib/sphinxext/__pycache__/mathmpl.cpython-312.pyc,, +matplotlib/sphinxext/__pycache__/plot_directive.cpython-312.pyc,, +matplotlib/sphinxext/__pycache__/roles.cpython-312.pyc,, +matplotlib/sphinxext/figmpl_directive.py,sha256=rgoJcGRfLdp4jiOlyBIwG83wcyfzbFVfrlrSCmDHL9g,9308 +matplotlib/sphinxext/mathmpl.py,sha256=CKIocBSV4prRMPR50NL6WxBTULBa1zReoqo7ZtbvCsc,7871 +matplotlib/sphinxext/plot_directive.py,sha256=6GEOX2y0K1Izc98Aojt8pCXS21Ucru6E-T_33lRU92s,32542 +matplotlib/sphinxext/roles.py,sha256=U7P4t06foLF2PsZJrTN4mfaspKGRoYwFZ3ijIhIUVyo,4882 +matplotlib/spines.py,sha256=rKAoBueuqmSids1SKFgWWgMtvUxo2HOhyw73Ri4HyOc,21643 +matplotlib/spines.pyi,sha256=eceCS47bOawgkWdLUMIPt6XX_H-EtWS2t-euz5Bgkbg,2951 +matplotlib/stackplot.py,sha256=a6IUAujDjGpaFqiTzGr85eDD8l6baWLPrM5YNvb6g9I,4997 +matplotlib/stackplot.pyi,sha256=BX2g3-6dJ4RhjjIguo-gwWhvKZNYqFCQYVw5SKvpM4c,561 +matplotlib/streamplot.py,sha256=rXVKYEi4MfSg3HnAaWb8w4qFp95mRf6dosMVqtXZEpE,24011 +matplotlib/streamplot.pyi,sha256=fkdkew8u807qd7WXFd_ZjF7FUbx8okOI8A7u4x_ktvg,2690 +matplotlib/style/__init__.py,sha256=fraQtyBC3TY2ZTsLdxL7zNs9tJYZtje7tbiqEf3M56M,140 +matplotlib/style/__pycache__/__init__.cpython-312.pyc,, +matplotlib/style/__pycache__/core.cpython-312.pyc,, +matplotlib/style/core.py,sha256=RSNH2NO0xK_Y37dbpMWyaWMMjvcMa_NHaKXDa5I4_tA,8362 +matplotlib/style/core.pyi,sha256=mIlyz6eChdMjZ_A7S05iJgcWwrWE2NXg2rhm1C1eYlQ,521 +matplotlib/table.py,sha256=MkF9s8u2X-9_Cei9sJixljb2_I_B_4UOqVx0iReKIU0,27744 +matplotlib/table.pyi,sha256=tcR40hoCWCRLlL-8PtFMa7P5sq8qajDSMFxd25Q90z4,3098 +matplotlib/testing/__init__.py,sha256=pTaqH1qgGNBeWCmvK_vjqFJXJ9dvZXCaKQ-8jHSpaEc,6942 +matplotlib/testing/__init__.pyi,sha256=ffqfetWzyCVrSx7BlnoxCmbgIaZg2x57nDrq9eucRk0,1752 +matplotlib/testing/__pycache__/__init__.cpython-312.pyc,, +matplotlib/testing/__pycache__/_markers.cpython-312.pyc,, +matplotlib/testing/__pycache__/compare.cpython-312.pyc,, +matplotlib/testing/__pycache__/conftest.cpython-312.pyc,, +matplotlib/testing/__pycache__/decorators.cpython-312.pyc,, +matplotlib/testing/__pycache__/exceptions.cpython-312.pyc,, +matplotlib/testing/__pycache__/widgets.cpython-312.pyc,, +matplotlib/testing/_markers.py,sha256=0iNyOi25XLv_gTfSUqiRizdSqJzozePPBMRo72H2Je4,1419 +matplotlib/testing/compare.py,sha256=8dVcuP7FauHY1JZQXBD7fNBfQKb4IoXM6pVeq6zJgBs,19780 +matplotlib/testing/compare.pyi,sha256=xlJ4chgXKe567NUavlu-dalyPr4wAQUbd2Fz6aK_JII,1192 +matplotlib/testing/conftest.py,sha256=CcwfUtIkUOgGBlXXNplQE6dg2U_UBKdH6AajROPJ5uw,6683 +matplotlib/testing/conftest.pyi,sha256=zOF_MM2GjCkNi9nZ8vldV9VI41bD67nVhrIgLVuMNsk,416 +matplotlib/testing/decorators.py,sha256=1Xiyb2exeN0nDEO4he8MW_VKwFE34cB7EtRl6bcGRzg,18022 +matplotlib/testing/decorators.pyi,sha256=0fSpdLBtEH7ZP_trVJ7RPxNtOX9sJ_z-MkNsbUxF8nM,872 +matplotlib/testing/exceptions.py,sha256=72QmjiHG7DwxSvlJf8mei-hRit5AH3NKh0-osBo4YbY,138 +matplotlib/testing/jpl_units/Duration.py,sha256=9FMBu9uj6orCWtf23cf6_9HCFUC50xAHrCzaxATwQfM,3966 +matplotlib/testing/jpl_units/Epoch.py,sha256=-FGxeq-VvCS9GVPwOEE5ind_G4Tl9ztD-gYcW9CWzjo,6100 +matplotlib/testing/jpl_units/EpochConverter.py,sha256=fhWjyP567bzcTU_oNuJJpucoolqS88Nt-yEFg1-3yEk,2944 +matplotlib/testing/jpl_units/StrConverter.py,sha256=codGw9b_Zc-MG_YK4CiyMrnMR8ahR9hw836O2SsV8QI,2865 +matplotlib/testing/jpl_units/UnitDbl.py,sha256=EABjyEK4MVouyvlwi_9KdYDg-qbYY3aLHoUjRw37Fb0,5882 +matplotlib/testing/jpl_units/UnitDblConverter.py,sha256=B8DssrQVyC4mwvSFP78cGL0vCnZgVhDaAbZE-jsXLUg,2828 +matplotlib/testing/jpl_units/UnitDblFormatter.py,sha256=246hgA4_pCfJm-P94hEsxqnTS9t0XlvLC8p1v_bw2pU,657 +matplotlib/testing/jpl_units/__init__.py,sha256=p__9RUwrt2LJ2eoT2JPM-42XLxSJrfA4az3rN5uP6d4,2684 +matplotlib/testing/jpl_units/__pycache__/Duration.cpython-312.pyc,, +matplotlib/testing/jpl_units/__pycache__/Epoch.cpython-312.pyc,, +matplotlib/testing/jpl_units/__pycache__/EpochConverter.cpython-312.pyc,, +matplotlib/testing/jpl_units/__pycache__/StrConverter.cpython-312.pyc,, +matplotlib/testing/jpl_units/__pycache__/UnitDbl.cpython-312.pyc,, +matplotlib/testing/jpl_units/__pycache__/UnitDblConverter.cpython-312.pyc,, +matplotlib/testing/jpl_units/__pycache__/UnitDblFormatter.cpython-312.pyc,, +matplotlib/testing/jpl_units/__pycache__/__init__.cpython-312.pyc,, +matplotlib/testing/widgets.py,sha256=IaoPHgDguJSHd7t97kSVWUZvqTQ-Tsr9tU9kfnmJrAM,3480 +matplotlib/testing/widgets.pyi,sha256=Ioau7Q2aPRDZLx8hze2DOe3E1vn7QPxePC74WMR7tFc,831 +matplotlib/tests/__init__.py,sha256=XyXveEAxafB87gnbx0jkC0MggzKO8FvORq_6RtJRwo4,366 +matplotlib/tests/__pycache__/__init__.cpython-312.pyc,, +matplotlib/tests/__pycache__/conftest.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_afm.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_agg.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_agg_filter.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_animation.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_api.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_arrow_patches.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_artist.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_axes.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_axis.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_backend_bases.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_backend_cairo.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_backend_gtk3.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_backend_inline.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_backend_macosx.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_backend_nbagg.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_backend_pdf.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_backend_pgf.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_backend_ps.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_backend_qt.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_backend_registry.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_backend_svg.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_backend_template.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_backend_tk.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_backend_tools.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_backend_webagg.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_backends_interactive.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_basic.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_bbox_tight.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_bezier.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_category.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_cbook.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_collections.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_colorbar.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_colors.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_compare_images.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_constrainedlayout.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_container.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_contour.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_cycles.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_dates.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_datetime.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_determinism.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_doc.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_dviread.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_figure.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_font_manager.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_fontconfig_pattern.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_ft2font.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_getattr.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_gridspec.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_image.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_legend.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_lines.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_marker.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_mathtext.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_matplotlib.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_mlab.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_multivariate_colormaps.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_offsetbox.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_patches.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_path.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_patheffects.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_pickle.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_png.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_polar.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_preprocess_data.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_pyplot.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_quiver.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_rcparams.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_sankey.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_scale.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_simplification.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_skew.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_sphinxext.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_spines.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_streamplot.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_style.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_subplots.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_table.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_testing.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_texmanager.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_text.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_textpath.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_ticker.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_tightlayout.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_transforms.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_triangulation.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_type1font.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_units.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_usetex.cpython-312.pyc,, +matplotlib/tests/__pycache__/test_widgets.cpython-312.pyc,, +matplotlib/tests/conftest.py,sha256=0cKxWVUC25UfQJQcAjFJa73MQE9eSYqKSXGDx7kAq48,138 +matplotlib/tests/test_afm.py,sha256=A7jm2o-QaQH9SSpiFxGtZkbVU0LJZE69jfPv7RczOD4,3701 +matplotlib/tests/test_agg.py,sha256=xAKrTzvT1dNM_49efiWisnGNrJtINOBenYSOtaRXvIg,10884 +matplotlib/tests/test_agg_filter.py,sha256=3c_Smtb4OHEOfdMFCOb2qKhzMXSbNoaUtsJ0pW47Q44,1067 +matplotlib/tests/test_animation.py,sha256=z0FJ0gx15rJB_khEPSg1E-vLGQyTj85Ki-uTjFsMEPE,18357 +matplotlib/tests/test_api.py,sha256=UJU_qoi7Y8Rbz1S89NYKvOkNwv_RBVPNFajrcXixNds,5736 +matplotlib/tests/test_arrow_patches.py,sha256=qgbS9OrlHa4TJihAd2ekW4AupCVgKDd2B5YyF704Pak,6577 +matplotlib/tests/test_artist.py,sha256=oblCTkCPZzufghCp19AKwIJftV69tZwEOjeRLn8soAQ,18599 +matplotlib/tests/test_axes.py,sha256=NyVdODWyq-ODna0ElxJW_sTDKw2iDx8_BD_cC7juIyU,328610 +matplotlib/tests/test_axis.py,sha256=Ljc_Yc7dAFkTuVW0PD0i3vEnjkGOuAehkpcNpWg9A7Y,1446 +matplotlib/tests/test_backend_bases.py,sha256=FwUgearYkC7UgqpRpaKk51o5F62ipDL4kUvFROwGVLo,22775 +matplotlib/tests/test_backend_cairo.py,sha256=O2LTYjsfPn__bKtTz4MGGBodpSshoPkzu0INsc18xmI,1821 +matplotlib/tests/test_backend_gtk3.py,sha256=Zb29s85nbRzwLVmChCA5_LphAeC_yKyP45kCcHxfgPE,2849 +matplotlib/tests/test_backend_inline.py,sha256=b7GrmMCrJfI5kXGJKbxdMM9kWBd-JKUdCkRq-vk8JXo,1608 +matplotlib/tests/test_backend_macosx.py,sha256=s4Kd1fnwYCuamGRLtkOAYtzer7jVOjLaA2LH52hhPqM,2233 +matplotlib/tests/test_backend_nbagg.py,sha256=o6ON7tt_LOW7wzg4StIMYuTgMFSPNWt1FOv2FFMIefk,1459 +matplotlib/tests/test_backend_pdf.py,sha256=CxVYTlz8_AQ7o90yh5cavpSnNBLozIL8UhscRYShUVM,14602 +matplotlib/tests/test_backend_pgf.py,sha256=WlGdZThz5uQ-x8-HqpMcB5IWmYijflNCC-rVNgvz0yw,13028 +matplotlib/tests/test_backend_ps.py,sha256=veHbm4_dpBDcczgomEjQxdz-Sv8wHij3LVSKWST9Bd0,12600 +matplotlib/tests/test_backend_qt.py,sha256=VTWiuGw-evGdc2OzaKRFU0MXvtwxInvZNXBr51y_Jg4,13000 +matplotlib/tests/test_backend_registry.py,sha256=SiyyRRMT1tIXKxwn7IXq0SaIrYt3DEvBBe8TvNxa644,5950 +matplotlib/tests/test_backend_svg.py,sha256=L73hpBa1P-y978Y3Gpd_eSJAFJMvUvrOqiYaJ-CkQUw,22930 +matplotlib/tests/test_backend_template.py,sha256=uuE7oZ9pSBVDWrPL05B0WgCFsgv6HlXyetuPTfJn6a8,2184 +matplotlib/tests/test_backend_tk.py,sha256=A1h3AfPhEMbG5PLdDr1gbnwt_DlaQOBgFux5QsIpCpM,8850 +matplotlib/tests/test_backend_tools.py,sha256=C-B7NCkyWsQ5KzQEnI5Be16DsAHHZJU9P5v9--wsF-o,501 +matplotlib/tests/test_backend_webagg.py,sha256=4Oc-z7w-mTGETB1x0FQ_gZP9qHfyWh5lwWc9qPkkisc,938 +matplotlib/tests/test_backends_interactive.py,sha256=uzbU_sKZVaKmKDFW0FgGRauPvVnmsFRlXQA5XB_UvKo,29020 +matplotlib/tests/test_basic.py,sha256=ubAnlE-lFQzMhoBlWYZJKCAsox9Y3jBXS_IMn29Zi84,1141 +matplotlib/tests/test_bbox_tight.py,sha256=5yo6yy3HTUIf2-oGZllieaLynmOupzWcuPpeGmgS5BQ,6314 +matplotlib/tests/test_bezier.py,sha256=IrrDWAV5O6ELMdziaaOeBI7LfyEaTBVsT4B1T9KMzD8,692 +matplotlib/tests/test_category.py,sha256=jWXOAGOR_gpvrjarvJB6Tyot0yOIWaO6d1F9mrNcVPo,12043 +matplotlib/tests/test_cbook.py,sha256=85PW_AjpGEqr6SfL8CjTY-kZ7TA0RMdrz8Gi3Dyc91g,33530 +matplotlib/tests/test_collections.py,sha256=3hg_WB-zv409PgIchCM7df5gVOiwYXj0H2n0ZFjrlmU,48406 +matplotlib/tests/test_colorbar.py,sha256=tDdmWz_LxCbD80ksM6iqrHYqpombyL8QtqpKQSKOS14,46711 +matplotlib/tests/test_colors.py,sha256=-Q5VZfQWI-NSMOa4tusxDgWe705a7uymMHLI8h10C8A,61215 +matplotlib/tests/test_compare_images.py,sha256=NcBoT8HAxtzoR1zZBu0V3MFpGWtdFaDblQ8o3OTO6zM,3260 +matplotlib/tests/test_constrainedlayout.py,sha256=WWQM9nDpVI8OLAQ_P88PqO2-hMiB7pBGAp0DnZBPJmM,23602 +matplotlib/tests/test_container.py,sha256=FTewckOd3dJqLOzEUa29Itjqusk7Mx7MK5xPVMhzMmc,694 +matplotlib/tests/test_contour.py,sha256=O_VIhjDgsb0A-brYR-gJ-WUcqzYzH0XLQD4aI_5SocM,30289 +matplotlib/tests/test_cycles.py,sha256=3KyRmWH29WUgvIXUT06tKVDNCfDWqxuxlLueIp-FIl0,5996 +matplotlib/tests/test_dates.py,sha256=jOK-KIAMBRCcnlgpzknpLJb3JvO3xFa5Qe3O5mVTeGM,56533 +matplotlib/tests/test_datetime.py,sha256=n2TYVItNQcUFBLUivuTXBnc0fElIeSmy9hu4ZNMVurc,32635 +matplotlib/tests/test_determinism.py,sha256=harG-hnOpD5HZFKPtHebT0snHu8V6C6BScVFml1Mqyg,7958 +matplotlib/tests/test_doc.py,sha256=K6HhdRcHRUNNC0iIhxBLCq5tOqxqHBSvanHxXg5w5iI,1015 +matplotlib/tests/test_dviread.py,sha256=JeTuA2FMUj1FddxDVBXUtnvZYTgztE-CyRXL_mI20P0,2764 +matplotlib/tests/test_figure.py,sha256=bGyAW9fwJc3gVVul_Vd5f8TEmbrEoDAMWV4Rm5-QXQQ,60289 +matplotlib/tests/test_font_manager.py,sha256=Qkewu4tMylgVciamVwWL8TFoDon8YyzN-9po6usN6Mk,14136 +matplotlib/tests/test_fontconfig_pattern.py,sha256=LSR6lWF_0cVOshkGiflYaTCLcISRIJM6mjXm5QtDjX4,2168 +matplotlib/tests/test_ft2font.py,sha256=lz13lRGmxdQ9eRjQlnigdfQWBwaST1A3_AgEAR5dJA0,40838 +matplotlib/tests/test_getattr.py,sha256=Tl_H1zpwLdSIVutc4vi-QwDCeWPzBGpN31N9ItzTkeQ,1090 +matplotlib/tests/test_gridspec.py,sha256=SYJuJucA_PyQ2Rsov1RaNhadOEWGDcMbQcVFrWIoy3I,1580 +matplotlib/tests/test_image.py,sha256=O9pUxNrGyewPievlC-WDhJ3hkCTsiAq4id6E6hJ22Is,59562 +matplotlib/tests/test_legend.py,sha256=MQL_blp2W5CUXFRGz8kFZzcVZ8ipnwkWr43-W-3B0HI,54829 +matplotlib/tests/test_lines.py,sha256=KElNi2GlJCtGLhNpf8rl8bkDhMcQ9ittyRdsX4rdSxo,15035 +matplotlib/tests/test_marker.py,sha256=w0WVHoaD-6iybjUSENoVFFdUOOR13943JcE4sgz3qhI,11410 +matplotlib/tests/test_mathtext.py,sha256=2S1i3BLNffEqz1t-mehhWBROBcsdBZsdgrticxW6Xrw,24549 +matplotlib/tests/test_matplotlib.py,sha256=YPBZQdO6BEC4mPKxXD_W-HoFzoOZwGGifYrYdCDcy48,2887 +matplotlib/tests/test_mlab.py,sha256=d4qMyogTFMrvlRZEpDs7SjhSmmCnBUMNgSX2bJU6eDk,42269 +matplotlib/tests/test_multivariate_colormaps.py,sha256=wGc08cdUTBDJvhUiwVqTrBS7o19Ei-wQRnWlkny7h5M,20785 +matplotlib/tests/test_offsetbox.py,sha256=m6rezy7tb5YUgiITMIpOHdDV56DBx-Cy32XFa_WTcd4,16137 +matplotlib/tests/test_patches.py,sha256=4pWXTevmwDPMn2Jw_qyynXUyBvGoTV_xo5P16PSLQLU,33466 +matplotlib/tests/test_path.py,sha256=d5xXaTscikS4R8JKMVDcwgmXIhFVQaPFY-kYCAJxddY,22158 +matplotlib/tests/test_patheffects.py,sha256=q7JA5VbZU5Zsqvc2eGM7YhB9TClkNp3akQCZVsHrNQU,8109 +matplotlib/tests/test_pickle.py,sha256=2VcsJ6QwN9vP4cvkuUCZD7uwKyd1rOCDIaYPEBr3u6E,10014 +matplotlib/tests/test_png.py,sha256=d6u6UkU71T6ULxDVSdMkIT3CUAL46dEuDuNrnjZe5BE,1407 +matplotlib/tests/test_polar.py,sha256=_h3VVisv2bMpaaRFhGEzbJe9jnzzEtKM_EvaHnw6_xA,17607 +matplotlib/tests/test_preprocess_data.py,sha256=cIVICUi1iahMQS30sqI5IlT2RYJRH2gJ0z60FyuYUFk,11363 +matplotlib/tests/test_pyplot.py,sha256=5-Rv0g037ifl64vWybmlV8y22YjuY5lVX5aPvnzihiA,13910 +matplotlib/tests/test_quiver.py,sha256=5zSF_0DpoDXPBvGWD6iu1VfIUna15npLd2UyCG6_Ksg,11953 +matplotlib/tests/test_rcparams.py,sha256=PLos0asOBQMn0hjrVP_itywcbZjT8og_TaB3tqSFHac,26506 +matplotlib/tests/test_sankey.py,sha256=yg4i-qHT5MljFzFGViOnHbMkumM8bhPwRgoQ5M6CUEs,3900 +matplotlib/tests/test_scale.py,sha256=fqLu88lVy7-JZFNslpgiid11_TIivSkM9DxVnHwZJYo,8429 +matplotlib/tests/test_simplification.py,sha256=IJ1yEz2Y58QtKRRHz41NaXVS4kkRipaE_Un0HgM4K2g,21570 +matplotlib/tests/test_skew.py,sha256=_Mjwfgce6WxLH3dafI7irv2Cw0ANifytCtQXdyOStCk,6349 +matplotlib/tests/test_sphinxext.py,sha256=9hzVcUgMhtfo_6A_c-SvCYWDQY45Oq-lD6Os4PnRg3s,9937 +matplotlib/tests/test_spines.py,sha256=O7jKGFJ1hv50k94i1fjPYNsKljDaWDHi9R5fo2bcXI0,4909 +matplotlib/tests/test_streamplot.py,sha256=mSLuZ7E2eEtcI5nKHBZZ_30OYte5awqsXWFWROJYtCQ,5731 +matplotlib/tests/test_style.py,sha256=sd6rMLpPBS1hinR2svI1rXZmnXUnMQk1szxSNBUXtzU,6509 +matplotlib/tests/test_subplots.py,sha256=7weokAQ2dmYwGAXAwqN-tfjZDrNfHxPmGgTlzI6TBAU,10771 +matplotlib/tests/test_table.py,sha256=QsrDRpe864LP3zkQBtTciKub5raYz3i3Vrt_Ve3kdzQ,8659 +matplotlib/tests/test_testing.py,sha256=eh-1r4PIXcM7hfSKCNTU899QxRYWhZBg6W7x0TDVUxo,1057 +matplotlib/tests/test_texmanager.py,sha256=j_5ZG02ztIO-m0oXzd31a13YFw_G7JR7Ldt3fwf-phI,2647 +matplotlib/tests/test_text.py,sha256=3RxKrfJJBWKFGSrEvxeCG5rxcvlkIivir8wCj1ls5-M,38473 +matplotlib/tests/test_textpath.py,sha256=WLXvT5OzE6Tew4pr87LH-cgioCzM7srgMNRemiMEC5o,271 +matplotlib/tests/test_ticker.py,sha256=Bj3hiEgXMBXzDi0Lmg8k-Qg4ChPZrQVqGU0Qfh7Sv84,74759 +matplotlib/tests/test_tightlayout.py,sha256=KU0m9BnpbKV-jBdaI4EbDLE5Cf_BLtF0Y9hcFfQ4zlQ,13978 +matplotlib/tests/test_transforms.py,sha256=NfWkPWOKAmY1sq_R1T85ct4CYdFmYUjmjtg2uwOYIIY,48671 +matplotlib/tests/test_triangulation.py,sha256=hstVYhH09BoVl8L0n6vHXBabniZLUu2KYl6wqZn2Xac,55289 +matplotlib/tests/test_type1font.py,sha256=gZZDFi0buFus_aAgOrmtwKqbC36-88UkvM8Afbcngs4,6369 +matplotlib/tests/test_units.py,sha256=0v7JNqjkA8MVnvlOeFoYqQ3sW1KjRY_ijp8BGM8ZW_U,11675 +matplotlib/tests/test_usetex.py,sha256=a-Y6NuyROPHDGP2ELsOZNSVwBUoGtAv1xQZfisl9lSE,6405 +matplotlib/tests/test_widgets.py,sha256=U2FMCCJyKD-pcLbzGzEBAL91-3Fx80wsYdinF7eCOrg,66435 +matplotlib/texmanager.py,sha256=vP9rzv_jYdsexQoAAdMt9rLd6-MwWFX6JGFX9pG4nfg,15033 +matplotlib/texmanager.pyi,sha256=di3gbC9muXKTo5VCrW5ye-e19A1mrOl8e8lvI3b904A,1116 +matplotlib/text.py,sha256=xYSkLZiOpew4G1jkLA5pUjtF7qUhOs3yp8IEFMqE6Gg,70856 +matplotlib/text.pyi,sha256=EL_-kiqz88kuPs0hss6IucRrOCsLJ4zaKOYRFV5Wbk8,7019 +matplotlib/textpath.py,sha256=QmrZQ2Mtfyao6qWg-wmEGAbTwkuJ54AnAzFf9NzZHg4,13254 +matplotlib/textpath.pyi,sha256=rqOeTAeQYgm2b2NpetrEX0gMF8PzzW43xS5mNfUA98M,2529 +matplotlib/ticker.py,sha256=YoIJyWVlYk3DIZbBl-w6fzkuVES3j1dN4nyf-SHKVDg,107381 +matplotlib/ticker.pyi,sha256=YvnQDHxJT9H-iomDZpvBCoYkfRRYSot2MR0H0pjZM78,10604 +matplotlib/transforms.py,sha256=2v-golPy7KIqabRM1cQolEF6UcbyXwKrhwcpssGe9Pk,99707 +matplotlib/transforms.pyi,sha256=0I70FR4ZWmRoo6w5KjbXRrH0dVDmQyPFbGt_sNTYq10,12102 +matplotlib/tri/__init__.py,sha256=asnfefKRpJv7sGbfddCMybnJInVDPwgph7g0mpoh2u4,820 +matplotlib/tri/__pycache__/__init__.cpython-312.pyc,, +matplotlib/tri/__pycache__/_triangulation.cpython-312.pyc,, +matplotlib/tri/__pycache__/_tricontour.cpython-312.pyc,, +matplotlib/tri/__pycache__/_trifinder.cpython-312.pyc,, +matplotlib/tri/__pycache__/_triinterpolate.cpython-312.pyc,, +matplotlib/tri/__pycache__/_tripcolor.cpython-312.pyc,, +matplotlib/tri/__pycache__/_triplot.cpython-312.pyc,, +matplotlib/tri/__pycache__/_trirefine.cpython-312.pyc,, +matplotlib/tri/__pycache__/_tritools.cpython-312.pyc,, +matplotlib/tri/_triangulation.py,sha256=Ur2lKMOx4NrZxwyi0hBeBnVzicuKaCke0NkrZneSklM,9784 +matplotlib/tri/_triangulation.pyi,sha256=pVw1rvpIcl00p7V7E9GcvJSqQWyoxlZXX_p0_VSxTiY,1017 +matplotlib/tri/_tricontour.py,sha256=yxeH8QgBub1nTq9qbO5Iiiz81bcLXDeMcJHUS2Ahmns,10220 +matplotlib/tri/_tricontour.pyi,sha256=jnsAmVRX0-FOUw9ptUgci9J4T4JQRloKeH8fh8aAi-o,1155 +matplotlib/tri/_trifinder.py,sha256=3gUzJZDIwfdsSJUE8hIKso9e1-UGvynUN9HxaqC1EEc,3522 +matplotlib/tri/_trifinder.pyi,sha256=dXcZucacAS3Ch6nrDBPh2e3LYZLfZ7VwqpBUBb-vMPo,405 +matplotlib/tri/_triinterpolate.py,sha256=4FtyJSoJpHcFxkSkZHZ1aNengVNWqVKF4l78PgCH8O0,62445 +matplotlib/tri/_triinterpolate.pyi,sha256=nR0o0Jm0uPH-6l0ft1WRHqYlq-6o84X6M0aX1UJ9IvE,1044 +matplotlib/tri/_tripcolor.py,sha256=Z6urFv-naDM3_rhv_pkgIZI53h88aOo9qWc3MH7GmBA,6705 +matplotlib/tri/_tripcolor.pyi,sha256=QsA-A2ohj3r_tAElt2-9pzi47JiU01tNlRPDIptqnh4,1781 +matplotlib/tri/_triplot.py,sha256=jlHSz36Z5S18zBKc639PlSqdhfl7jHol8ExlddJuDI4,3102 +matplotlib/tri/_triplot.pyi,sha256=9USU-BfitrcdQE8yWOUlBX59QBNoHCWivDon9JbDQ0k,446 +matplotlib/tri/_trirefine.py,sha256=NG8bsDhZ5EOxMT-MsEWzJm11ZR3_8CAYHlG53IGi0ps,13178 +matplotlib/tri/_trirefine.pyi,sha256=J_PmjbeI6UbLaeecgj1OCvGe_sr9UUsNK9NGBSlQ320,1056 +matplotlib/tri/_tritools.py,sha256=wC9KVE6UqkWVHpyW9FU4hQdqRVRVmJlhaBF1EXsaD8U,10575 +matplotlib/tri/_tritools.pyi,sha256=XWwwvH2nIAmH8k59aRjnLBVQbTwKvd_FzdsRNASCJMw,402 +matplotlib/typing.py,sha256=ZxpQ5jAqiprq3UkqWParWJITgIuOi4ZJ6WEM_q3QOis,2439 +matplotlib/units.py,sha256=7O-llc8k3GpdotUs2tWcEGgoUHHX-Y7o0R7f-1Jve3k,6429 +matplotlib/widgets.py,sha256=IbxivJqDYlQ9_utWiJpwGLxeYqPeIYLNcy2oLRjuigM,152101 +matplotlib/widgets.pyi,sha256=y4Mp69j-s0zJSVxYdmjoxR4F0bWAYApYdmW6cTW3GBc,15370 +mpl_toolkits/axes_grid1/__init__.py,sha256=wiuUCQo1g20SW5T3mFOmI9dGCJY6aDmglpQw5DfszEU,371 +mpl_toolkits/axes_grid1/__pycache__/__init__.cpython-312.pyc,, +mpl_toolkits/axes_grid1/__pycache__/anchored_artists.cpython-312.pyc,, +mpl_toolkits/axes_grid1/__pycache__/axes_divider.cpython-312.pyc,, +mpl_toolkits/axes_grid1/__pycache__/axes_grid.cpython-312.pyc,, +mpl_toolkits/axes_grid1/__pycache__/axes_rgb.cpython-312.pyc,, +mpl_toolkits/axes_grid1/__pycache__/axes_size.cpython-312.pyc,, +mpl_toolkits/axes_grid1/__pycache__/inset_locator.cpython-312.pyc,, +mpl_toolkits/axes_grid1/__pycache__/mpl_axes.cpython-312.pyc,, +mpl_toolkits/axes_grid1/__pycache__/parasite_axes.cpython-312.pyc,, +mpl_toolkits/axes_grid1/anchored_artists.py,sha256=ZO5bIF_29sWrk-lWocKJ_45OQdluf_gu7nLrCtfKtdY,17161 +mpl_toolkits/axes_grid1/axes_divider.py,sha256=JD3jGBAqRHRYb1jablrpq_RFQvYtYfwtIIPJgQpbKPc,21892 +mpl_toolkits/axes_grid1/axes_grid.py,sha256=sjEbq6hu7YFimoOYVIcaccEGXsSljNPaTLfJjFHWVtM,22344 +mpl_toolkits/axes_grid1/axes_rgb.py,sha256=pabgaWJuLTCPw2FlT6Zfy5d0_95CEvaLeosWRTElR98,5227 +mpl_toolkits/axes_grid1/axes_size.py,sha256=UAEQ-t0qeJlQcwbbrY4tK2NuSWheFyUXIkk4BUCauHc,7713 +mpl_toolkits/axes_grid1/inset_locator.py,sha256=_2U8ZAj_x7gjKhPk40VSbzE7L6wO2VY-OoOMfd6xOJs,19649 +mpl_toolkits/axes_grid1/mpl_axes.py,sha256=vFCttnj9JIgY3Mt2eOi-O_FVvdZ6SW_sBtIBFib6bz4,4251 +mpl_toolkits/axes_grid1/parasite_axes.py,sha256=809Uy3bLgXIDIGztefcvS9EoZcu3KfMV1QwLzX8qVT4,9404 +mpl_toolkits/axes_grid1/tests/__init__.py,sha256=sKLxL9jEJBX7eh5OumtXSOnTriPrJUkujTHFtnJVFrM,365 +mpl_toolkits/axes_grid1/tests/__pycache__/__init__.cpython-312.pyc,, +mpl_toolkits/axes_grid1/tests/__pycache__/conftest.cpython-312.pyc,, +mpl_toolkits/axes_grid1/tests/__pycache__/test_axes_grid1.cpython-312.pyc,, +mpl_toolkits/axes_grid1/tests/conftest.py,sha256=zB61sy90X97YJ16mIGiuaEAaBIjBEzRAK_qfSCichQM,147 +mpl_toolkits/axes_grid1/tests/test_axes_grid1.py,sha256=x-W845Cvl2O-eGGhpxKDayFg7-QQXNA94nODLCFPpJc,29093 +mpl_toolkits/axisartist/__init__.py,sha256=RPaNDl22FbmDP7ZRsku1yCqpoNqcclCk0a3rXj3G7fE,631 +mpl_toolkits/axisartist/__pycache__/__init__.cpython-312.pyc,, +mpl_toolkits/axisartist/__pycache__/angle_helper.cpython-312.pyc,, +mpl_toolkits/axisartist/__pycache__/axes_divider.cpython-312.pyc,, +mpl_toolkits/axisartist/__pycache__/axis_artist.cpython-312.pyc,, +mpl_toolkits/axisartist/__pycache__/axisline_style.cpython-312.pyc,, +mpl_toolkits/axisartist/__pycache__/axislines.cpython-312.pyc,, +mpl_toolkits/axisartist/__pycache__/floating_axes.cpython-312.pyc,, +mpl_toolkits/axisartist/__pycache__/grid_finder.cpython-312.pyc,, +mpl_toolkits/axisartist/__pycache__/grid_helper_curvelinear.cpython-312.pyc,, +mpl_toolkits/axisartist/__pycache__/parasite_axes.cpython-312.pyc,, +mpl_toolkits/axisartist/angle_helper.py,sha256=-mjKpaR1pLMJuoc0sx0_V3bv0iRPMrpS7r_WI0UYrCc,12952 +mpl_toolkits/axisartist/axes_divider.py,sha256=65xSCQ9cHSC3KE7J7HxS4bfsDTAbPmwyz1jJ43BqnBs,122 +mpl_toolkits/axisartist/axis_artist.py,sha256=9FY9yXl8eF5QuBvF8-Vipv_rmdpcOlA3Q48a5v7FoeA,38328 +mpl_toolkits/axisartist/axisline_style.py,sha256=9jbDkXEzMQiDHR-lDYKZEvTADtJwt2qlN1cErVUUdx0,6723 +mpl_toolkits/axisartist/axislines.py,sha256=QxKvChTaRPj0ovvxdrfr3pOzExEIf5svDwxRE7enEXg,16556 +mpl_toolkits/axisartist/floating_axes.py,sha256=kfhWKkmiy8tkXoVHvooTidXIlicU861VncElsUcBhLo,10337 +mpl_toolkits/axisartist/grid_finder.py,sha256=Hi2zwnQilavgrqWKScuSRba_WBPqqrbmErGU0XyAsOo,12265 +mpl_toolkits/axisartist/grid_helper_curvelinear.py,sha256=ofN7pPqEMh3r6bSf9eOHkAZEOrmhiNZV-Yig3jozkC4,12349 +mpl_toolkits/axisartist/parasite_axes.py,sha256=Ydi4-0Lbczr6K7Sz1-fRwK4Tm8KlHrOIumx67Xbo_9c,244 +mpl_toolkits/axisartist/tests/__init__.py,sha256=sKLxL9jEJBX7eh5OumtXSOnTriPrJUkujTHFtnJVFrM,365 +mpl_toolkits/axisartist/tests/__pycache__/__init__.cpython-312.pyc,, +mpl_toolkits/axisartist/tests/__pycache__/conftest.cpython-312.pyc,, +mpl_toolkits/axisartist/tests/__pycache__/test_angle_helper.cpython-312.pyc,, +mpl_toolkits/axisartist/tests/__pycache__/test_axis_artist.cpython-312.pyc,, +mpl_toolkits/axisartist/tests/__pycache__/test_axislines.cpython-312.pyc,, +mpl_toolkits/axisartist/tests/__pycache__/test_floating_axes.cpython-312.pyc,, +mpl_toolkits/axisartist/tests/__pycache__/test_grid_finder.cpython-312.pyc,, +mpl_toolkits/axisartist/tests/__pycache__/test_grid_helper_curvelinear.cpython-312.pyc,, +mpl_toolkits/axisartist/tests/conftest.py,sha256=zB61sy90X97YJ16mIGiuaEAaBIjBEzRAK_qfSCichQM,147 +mpl_toolkits/axisartist/tests/test_angle_helper.py,sha256=PwhJwBm2kk4uMyhdO5arQs8IlqSX2vN0hvUzI7YHqrw,5670 +mpl_toolkits/axisartist/tests/test_axis_artist.py,sha256=wt3bicVgUPnBX48-dH0Z6hboHgutIgwVpaGkcUZDeVU,2980 +mpl_toolkits/axisartist/tests/test_axislines.py,sha256=NXegrvEzVWovshta-qjbUKA2tpQcAbjYbfwkf6tKT6Y,4353 +mpl_toolkits/axisartist/tests/test_floating_axes.py,sha256=l24VB1SLrsJZOMMH2jmBny9ETha4AqAM5KokdGOa5Wk,4083 +mpl_toolkits/axisartist/tests/test_grid_finder.py,sha256=cwQLDOdcJbAY2E7dr8595yzuNh1_Yh80r_O8WGT2hMY,1156 +mpl_toolkits/axisartist/tests/test_grid_helper_curvelinear.py,sha256=OhHej0vCfCjJJknT7yIt4OxZd6OMJCXnFoT3pzqUtTo,7216 +mpl_toolkits/mplot3d/__init__.py,sha256=fH9HdMfFMvjbIWqy2gjQnm2m3ae1CvLiuH6LwKHo0kI,49 +mpl_toolkits/mplot3d/__pycache__/__init__.cpython-312.pyc,, +mpl_toolkits/mplot3d/__pycache__/art3d.cpython-312.pyc,, +mpl_toolkits/mplot3d/__pycache__/axes3d.cpython-312.pyc,, +mpl_toolkits/mplot3d/__pycache__/axis3d.cpython-312.pyc,, +mpl_toolkits/mplot3d/__pycache__/proj3d.cpython-312.pyc,, +mpl_toolkits/mplot3d/art3d.py,sha256=ovygkCV4P7dQ9xIicczbJyyAbaEy2C1HYnzjuV3COus,50548 +mpl_toolkits/mplot3d/axes3d.py,sha256=hCBda2QB6_OYyKEJOG0paPEgDPmLIcrEMkZuKqOUFho,157921 +mpl_toolkits/mplot3d/axis3d.py,sha256=eQPWo2TKbRsPY8JUt5drjBbyRdVKBgeT2EF4HGg51oU,29327 +mpl_toolkits/mplot3d/proj3d.py,sha256=6Hm6WPzeu_wjfeR8afrQ1nCfjS0p3wjvoSIxJWVlS0s,6349 +mpl_toolkits/mplot3d/tests/__init__.py,sha256=sKLxL9jEJBX7eh5OumtXSOnTriPrJUkujTHFtnJVFrM,365 +mpl_toolkits/mplot3d/tests/__pycache__/__init__.cpython-312.pyc,, +mpl_toolkits/mplot3d/tests/__pycache__/conftest.cpython-312.pyc,, +mpl_toolkits/mplot3d/tests/__pycache__/test_art3d.cpython-312.pyc,, +mpl_toolkits/mplot3d/tests/__pycache__/test_axes3d.cpython-312.pyc,, +mpl_toolkits/mplot3d/tests/__pycache__/test_legend3d.cpython-312.pyc,, +mpl_toolkits/mplot3d/tests/conftest.py,sha256=zB61sy90X97YJ16mIGiuaEAaBIjBEzRAK_qfSCichQM,147 +mpl_toolkits/mplot3d/tests/test_art3d.py,sha256=AdFcJf0vz1_b-PIn2cVonPHF1vZw2EOHvmjdgBRa5Yo,3317 +mpl_toolkits/mplot3d/tests/test_axes3d.py,sha256=Xf5M1DipyVfsBBF8dA3AXg5U0Hts194EfAh57VSupE4,92348 +mpl_toolkits/mplot3d/tests/test_legend3d.py,sha256=QGPaoaucJP9KIC68g8zmk4divB_w5PtQc4DMIHMpcA8,4343 +pylab.py,sha256=zUXU0l7e7C5jmDSJbM0GLQxBun3xzuXNf1tuoZYA6Xk,110 diff --git a/lib/python3.12/site-packages/matplotlib-3.10.3.dist-info/REQUESTED b/lib/python3.12/site-packages/matplotlib-3.10.3.dist-info/REQUESTED new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lib/python3.12/site-packages/matplotlib-3.10.3.dist-info/WHEEL b/lib/python3.12/site-packages/matplotlib-3.10.3.dist-info/WHEEL new file mode 100644 index 0000000000000000000000000000000000000000..d98ef534f680b37433e9ab0f8470bdbe56c303d8 --- /dev/null +++ b/lib/python3.12/site-packages/matplotlib-3.10.3.dist-info/WHEEL @@ -0,0 +1,6 @@ +Wheel-Version: 1.0 +Generator: meson +Root-Is-Purelib: false +Tag: cp312-cp312-manylinux_2_17_x86_64 +Tag: cp312-cp312-manylinux2014_x86_64 + diff --git a/lib/python3.12/site-packages/mpl_toolkits/axes_grid1/__init__.py b/lib/python3.12/site-packages/mpl_toolkits/axes_grid1/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..c55302485e3fd570f55a255314318c274adc9098 --- /dev/null +++ b/lib/python3.12/site-packages/mpl_toolkits/axes_grid1/__init__.py @@ -0,0 +1,10 @@ +from . import axes_size as Size +from .axes_divider import Divider, SubplotDivider, make_axes_locatable +from .axes_grid import AxesGrid, Grid, ImageGrid + +from .parasite_axes import host_subplot, host_axes + +__all__ = ["Size", + "Divider", "SubplotDivider", "make_axes_locatable", + "AxesGrid", "Grid", "ImageGrid", + "host_subplot", "host_axes"] diff --git a/lib/python3.12/site-packages/mpl_toolkits/axes_grid1/__pycache__/__init__.cpython-312.pyc b/lib/python3.12/site-packages/mpl_toolkits/axes_grid1/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b40bde0d9c61ce8906638427f6c229f0b8b0b076 Binary files /dev/null and b/lib/python3.12/site-packages/mpl_toolkits/axes_grid1/__pycache__/__init__.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/mpl_toolkits/axes_grid1/__pycache__/anchored_artists.cpython-312.pyc b/lib/python3.12/site-packages/mpl_toolkits/axes_grid1/__pycache__/anchored_artists.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1d7da11a3e1e003aadf901ae4070b19717c4afab Binary files /dev/null and b/lib/python3.12/site-packages/mpl_toolkits/axes_grid1/__pycache__/anchored_artists.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/mpl_toolkits/axes_grid1/__pycache__/axes_divider.cpython-312.pyc b/lib/python3.12/site-packages/mpl_toolkits/axes_grid1/__pycache__/axes_divider.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..af73f853c80ae67cc6bed2a2136d1eb93c7df0c8 Binary files /dev/null and b/lib/python3.12/site-packages/mpl_toolkits/axes_grid1/__pycache__/axes_divider.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/mpl_toolkits/axes_grid1/__pycache__/axes_grid.cpython-312.pyc b/lib/python3.12/site-packages/mpl_toolkits/axes_grid1/__pycache__/axes_grid.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..17c449fb15b9b9af8b28719ff4985f4aa27cdbfe Binary files /dev/null and b/lib/python3.12/site-packages/mpl_toolkits/axes_grid1/__pycache__/axes_grid.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/mpl_toolkits/axes_grid1/__pycache__/axes_rgb.cpython-312.pyc b/lib/python3.12/site-packages/mpl_toolkits/axes_grid1/__pycache__/axes_rgb.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7c95d7090a65fa5661c364f2e4781f4e184459f7 Binary files /dev/null and b/lib/python3.12/site-packages/mpl_toolkits/axes_grid1/__pycache__/axes_rgb.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/mpl_toolkits/axes_grid1/__pycache__/axes_size.cpython-312.pyc b/lib/python3.12/site-packages/mpl_toolkits/axes_grid1/__pycache__/axes_size.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4353957ef5fa575004d423adc511b1a49e0d94c5 Binary files /dev/null and b/lib/python3.12/site-packages/mpl_toolkits/axes_grid1/__pycache__/axes_size.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/mpl_toolkits/axes_grid1/__pycache__/inset_locator.cpython-312.pyc b/lib/python3.12/site-packages/mpl_toolkits/axes_grid1/__pycache__/inset_locator.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e18daef07eb8d15bab0f2cd27686345cdf132227 Binary files /dev/null and b/lib/python3.12/site-packages/mpl_toolkits/axes_grid1/__pycache__/inset_locator.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/mpl_toolkits/axes_grid1/__pycache__/mpl_axes.cpython-312.pyc b/lib/python3.12/site-packages/mpl_toolkits/axes_grid1/__pycache__/mpl_axes.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bf04b71288d2468f5ad20899153aef461112351c Binary files /dev/null and b/lib/python3.12/site-packages/mpl_toolkits/axes_grid1/__pycache__/mpl_axes.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/mpl_toolkits/axes_grid1/__pycache__/parasite_axes.cpython-312.pyc b/lib/python3.12/site-packages/mpl_toolkits/axes_grid1/__pycache__/parasite_axes.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a97316f11752f4be8ad52e81e176af12dc761c4c Binary files /dev/null and b/lib/python3.12/site-packages/mpl_toolkits/axes_grid1/__pycache__/parasite_axes.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/mpl_toolkits/axes_grid1/anchored_artists.py b/lib/python3.12/site-packages/mpl_toolkits/axes_grid1/anchored_artists.py new file mode 100644 index 0000000000000000000000000000000000000000..214b15843ebfaf2e2ee26e100a2194dfe092382d --- /dev/null +++ b/lib/python3.12/site-packages/mpl_toolkits/axes_grid1/anchored_artists.py @@ -0,0 +1,414 @@ +from matplotlib import transforms +from matplotlib.offsetbox import (AnchoredOffsetbox, AuxTransformBox, + DrawingArea, TextArea, VPacker) +from matplotlib.patches import (Rectangle, ArrowStyle, + FancyArrowPatch, PathPatch) +from matplotlib.text import TextPath + +__all__ = ['AnchoredDrawingArea', 'AnchoredAuxTransformBox', + 'AnchoredSizeBar', 'AnchoredDirectionArrows'] + + +class AnchoredDrawingArea(AnchoredOffsetbox): + def __init__(self, width, height, xdescent, ydescent, + loc, pad=0.4, borderpad=0.5, prop=None, frameon=True, + **kwargs): + """ + An anchored container with a fixed size and fillable `.DrawingArea`. + + Artists added to the *drawing_area* will have their coordinates + interpreted as pixels. Any transformations set on the artists will be + overridden. + + Parameters + ---------- + width, height : float + Width and height of the container, in pixels. + xdescent, ydescent : float + Descent of the container in the x- and y- direction, in pixels. + loc : str + Location of this artist. Valid locations are + 'upper left', 'upper center', 'upper right', + 'center left', 'center', 'center right', + 'lower left', 'lower center', 'lower right'. + For backward compatibility, numeric values are accepted as well. + See the parameter *loc* of `.Legend` for details. + pad : float, default: 0.4 + Padding around the child objects, in fraction of the font size. + borderpad : float, default: 0.5 + Border padding, in fraction of the font size. + prop : `~matplotlib.font_manager.FontProperties`, optional + Font property used as a reference for paddings. + frameon : bool, default: True + If True, draw a box around this artist. + **kwargs + Keyword arguments forwarded to `.AnchoredOffsetbox`. + + Attributes + ---------- + drawing_area : `~matplotlib.offsetbox.DrawingArea` + A container for artists to display. + + Examples + -------- + To display blue and red circles of different sizes in the upper right + of an Axes *ax*: + + >>> ada = AnchoredDrawingArea(20, 20, 0, 0, + ... loc='upper right', frameon=False) + >>> ada.drawing_area.add_artist(Circle((10, 10), 10, fc="b")) + >>> ada.drawing_area.add_artist(Circle((30, 10), 5, fc="r")) + >>> ax.add_artist(ada) + """ + self.da = DrawingArea(width, height, xdescent, ydescent) + self.drawing_area = self.da + + super().__init__( + loc, pad=pad, borderpad=borderpad, child=self.da, prop=None, + frameon=frameon, **kwargs + ) + + +class AnchoredAuxTransformBox(AnchoredOffsetbox): + def __init__(self, transform, loc, + pad=0.4, borderpad=0.5, prop=None, frameon=True, **kwargs): + """ + An anchored container with transformed coordinates. + + Artists added to the *drawing_area* are scaled according to the + coordinates of the transformation used. The dimensions of this artist + will scale to contain the artists added. + + Parameters + ---------- + transform : `~matplotlib.transforms.Transform` + The transformation object for the coordinate system in use, i.e., + :attr:`matplotlib.axes.Axes.transData`. + loc : str + Location of this artist. Valid locations are + 'upper left', 'upper center', 'upper right', + 'center left', 'center', 'center right', + 'lower left', 'lower center', 'lower right'. + For backward compatibility, numeric values are accepted as well. + See the parameter *loc* of `.Legend` for details. + pad : float, default: 0.4 + Padding around the child objects, in fraction of the font size. + borderpad : float, default: 0.5 + Border padding, in fraction of the font size. + prop : `~matplotlib.font_manager.FontProperties`, optional + Font property used as a reference for paddings. + frameon : bool, default: True + If True, draw a box around this artist. + **kwargs + Keyword arguments forwarded to `.AnchoredOffsetbox`. + + Attributes + ---------- + drawing_area : `~matplotlib.offsetbox.AuxTransformBox` + A container for artists to display. + + Examples + -------- + To display an ellipse in the upper left, with a width of 0.1 and + height of 0.4 in data coordinates: + + >>> box = AnchoredAuxTransformBox(ax.transData, loc='upper left') + >>> el = Ellipse((0, 0), width=0.1, height=0.4, angle=30) + >>> box.drawing_area.add_artist(el) + >>> ax.add_artist(box) + """ + self.drawing_area = AuxTransformBox(transform) + + super().__init__(loc, pad=pad, borderpad=borderpad, + child=self.drawing_area, prop=prop, frameon=frameon, + **kwargs) + + +class AnchoredSizeBar(AnchoredOffsetbox): + def __init__(self, transform, size, label, loc, + pad=0.1, borderpad=0.1, sep=2, + frameon=True, size_vertical=0, color='black', + label_top=False, fontproperties=None, fill_bar=None, + **kwargs): + """ + Draw a horizontal scale bar with a center-aligned label underneath. + + Parameters + ---------- + transform : `~matplotlib.transforms.Transform` + The transformation object for the coordinate system in use, i.e., + :attr:`matplotlib.axes.Axes.transData`. + size : float + Horizontal length of the size bar, given in coordinates of + *transform*. + label : str + Label to display. + loc : str + Location of the size bar. Valid locations are + 'upper left', 'upper center', 'upper right', + 'center left', 'center', 'center right', + 'lower left', 'lower center', 'lower right'. + For backward compatibility, numeric values are accepted as well. + See the parameter *loc* of `.Legend` for details. + pad : float, default: 0.1 + Padding around the label and size bar, in fraction of the font + size. + borderpad : float, default: 0.1 + Border padding, in fraction of the font size. + sep : float, default: 2 + Separation between the label and the size bar, in points. + frameon : bool, default: True + If True, draw a box around the horizontal bar and label. + size_vertical : float, default: 0 + Vertical length of the size bar, given in coordinates of + *transform*. + color : str, default: 'black' + Color for the size bar and label. + label_top : bool, default: False + If True, the label will be over the size bar. + fontproperties : `~matplotlib.font_manager.FontProperties`, optional + Font properties for the label text. + fill_bar : bool, optional + If True and if *size_vertical* is nonzero, the size bar will + be filled in with the color specified by the size bar. + Defaults to True if *size_vertical* is greater than + zero and False otherwise. + **kwargs + Keyword arguments forwarded to `.AnchoredOffsetbox`. + + Attributes + ---------- + size_bar : `~matplotlib.offsetbox.AuxTransformBox` + Container for the size bar. + txt_label : `~matplotlib.offsetbox.TextArea` + Container for the label of the size bar. + + Notes + ----- + If *prop* is passed as a keyword argument, but *fontproperties* is + not, then *prop* is assumed to be the intended *fontproperties*. + Using both *prop* and *fontproperties* is not supported. + + Examples + -------- + >>> import matplotlib.pyplot as plt + >>> import numpy as np + >>> from mpl_toolkits.axes_grid1.anchored_artists import ( + ... AnchoredSizeBar) + >>> fig, ax = plt.subplots() + >>> ax.imshow(np.random.random((10, 10))) + >>> bar = AnchoredSizeBar(ax.transData, 3, '3 data units', 4) + >>> ax.add_artist(bar) + >>> fig.show() + + Using all the optional parameters + + >>> import matplotlib.font_manager as fm + >>> fontprops = fm.FontProperties(size=14, family='monospace') + >>> bar = AnchoredSizeBar(ax.transData, 3, '3 units', 4, pad=0.5, + ... sep=5, borderpad=0.5, frameon=False, + ... size_vertical=0.5, color='white', + ... fontproperties=fontprops) + """ + if fill_bar is None: + fill_bar = size_vertical > 0 + + self.size_bar = AuxTransformBox(transform) + self.size_bar.add_artist(Rectangle((0, 0), size, size_vertical, + fill=fill_bar, facecolor=color, + edgecolor=color)) + + if fontproperties is None and 'prop' in kwargs: + fontproperties = kwargs.pop('prop') + + if fontproperties is None: + textprops = {'color': color} + else: + textprops = {'color': color, 'fontproperties': fontproperties} + + self.txt_label = TextArea(label, textprops=textprops) + + if label_top: + _box_children = [self.txt_label, self.size_bar] + else: + _box_children = [self.size_bar, self.txt_label] + + self._box = VPacker(children=_box_children, + align="center", + pad=0, sep=sep) + + super().__init__(loc, pad=pad, borderpad=borderpad, child=self._box, + prop=fontproperties, frameon=frameon, **kwargs) + + +class AnchoredDirectionArrows(AnchoredOffsetbox): + def __init__(self, transform, label_x, label_y, length=0.15, + fontsize=0.08, loc='upper left', angle=0, aspect_ratio=1, + pad=0.4, borderpad=0.4, frameon=False, color='w', alpha=1, + sep_x=0.01, sep_y=0, fontproperties=None, back_length=0.15, + head_width=10, head_length=15, tail_width=2, + text_props=None, arrow_props=None, + **kwargs): + """ + Draw two perpendicular arrows to indicate directions. + + Parameters + ---------- + transform : `~matplotlib.transforms.Transform` + The transformation object for the coordinate system in use, i.e., + :attr:`matplotlib.axes.Axes.transAxes`. + label_x, label_y : str + Label text for the x and y arrows + length : float, default: 0.15 + Length of the arrow, given in coordinates of *transform*. + fontsize : float, default: 0.08 + Size of label strings, given in coordinates of *transform*. + loc : str, default: 'upper left' + Location of the arrow. Valid locations are + 'upper left', 'upper center', 'upper right', + 'center left', 'center', 'center right', + 'lower left', 'lower center', 'lower right'. + For backward compatibility, numeric values are accepted as well. + See the parameter *loc* of `.Legend` for details. + angle : float, default: 0 + The angle of the arrows in degrees. + aspect_ratio : float, default: 1 + The ratio of the length of arrow_x and arrow_y. + Negative numbers can be used to change the direction. + pad : float, default: 0.4 + Padding around the labels and arrows, in fraction of the font size. + borderpad : float, default: 0.4 + Border padding, in fraction of the font size. + frameon : bool, default: False + If True, draw a box around the arrows and labels. + color : str, default: 'white' + Color for the arrows and labels. + alpha : float, default: 1 + Alpha values of the arrows and labels + sep_x, sep_y : float, default: 0.01 and 0 respectively + Separation between the arrows and labels in coordinates of + *transform*. + fontproperties : `~matplotlib.font_manager.FontProperties`, optional + Font properties for the label text. + back_length : float, default: 0.15 + Fraction of the arrow behind the arrow crossing. + head_width : float, default: 10 + Width of arrow head, sent to `.ArrowStyle`. + head_length : float, default: 15 + Length of arrow head, sent to `.ArrowStyle`. + tail_width : float, default: 2 + Width of arrow tail, sent to `.ArrowStyle`. + text_props, arrow_props : dict + Properties of the text and arrows, passed to `.TextPath` and + `.FancyArrowPatch`. + **kwargs + Keyword arguments forwarded to `.AnchoredOffsetbox`. + + Attributes + ---------- + arrow_x, arrow_y : `~matplotlib.patches.FancyArrowPatch` + Arrow x and y + text_path_x, text_path_y : `~matplotlib.text.TextPath` + Path for arrow labels + p_x, p_y : `~matplotlib.patches.PathPatch` + Patch for arrow labels + box : `~matplotlib.offsetbox.AuxTransformBox` + Container for the arrows and labels. + + Notes + ----- + If *prop* is passed as a keyword argument, but *fontproperties* is + not, then *prop* is assumed to be the intended *fontproperties*. + Using both *prop* and *fontproperties* is not supported. + + Examples + -------- + >>> import matplotlib.pyplot as plt + >>> import numpy as np + >>> from mpl_toolkits.axes_grid1.anchored_artists import ( + ... AnchoredDirectionArrows) + >>> fig, ax = plt.subplots() + >>> ax.imshow(np.random.random((10, 10))) + >>> arrows = AnchoredDirectionArrows(ax.transAxes, '111', '110') + >>> ax.add_artist(arrows) + >>> fig.show() + + Using several of the optional parameters, creating downward pointing + arrow and high contrast text labels. + + >>> import matplotlib.font_manager as fm + >>> fontprops = fm.FontProperties(family='monospace') + >>> arrows = AnchoredDirectionArrows(ax.transAxes, 'East', 'South', + ... loc='lower left', color='k', + ... aspect_ratio=-1, sep_x=0.02, + ... sep_y=-0.01, + ... text_props={'ec':'w', 'fc':'k'}, + ... fontproperties=fontprops) + """ + if arrow_props is None: + arrow_props = {} + + if text_props is None: + text_props = {} + + arrowstyle = ArrowStyle("Simple", + head_width=head_width, + head_length=head_length, + tail_width=tail_width) + + if fontproperties is None and 'prop' in kwargs: + fontproperties = kwargs.pop('prop') + + if 'color' not in arrow_props: + arrow_props['color'] = color + + if 'alpha' not in arrow_props: + arrow_props['alpha'] = alpha + + if 'color' not in text_props: + text_props['color'] = color + + if 'alpha' not in text_props: + text_props['alpha'] = alpha + + t_start = transform + t_end = t_start + transforms.Affine2D().rotate_deg(angle) + + self.box = AuxTransformBox(t_end) + + length_x = length + length_y = length*aspect_ratio + + self.arrow_x = FancyArrowPatch( + (0, back_length*length_y), + (length_x, back_length*length_y), + arrowstyle=arrowstyle, + shrinkA=0.0, + shrinkB=0.0, + **arrow_props) + + self.arrow_y = FancyArrowPatch( + (back_length*length_x, 0), + (back_length*length_x, length_y), + arrowstyle=arrowstyle, + shrinkA=0.0, + shrinkB=0.0, + **arrow_props) + + self.box.add_artist(self.arrow_x) + self.box.add_artist(self.arrow_y) + + text_path_x = TextPath(( + length_x+sep_x, back_length*length_y+sep_y), label_x, + size=fontsize, prop=fontproperties) + self.p_x = PathPatch(text_path_x, transform=t_start, **text_props) + self.box.add_artist(self.p_x) + + text_path_y = TextPath(( + length_x*back_length+sep_x, length_y*(1-back_length)+sep_y), + label_y, size=fontsize, prop=fontproperties) + self.p_y = PathPatch(text_path_y, **text_props) + self.box.add_artist(self.p_y) + + super().__init__(loc, pad=pad, borderpad=borderpad, child=self.box, + frameon=frameon, **kwargs) diff --git a/lib/python3.12/site-packages/mpl_toolkits/axes_grid1/axes_divider.py b/lib/python3.12/site-packages/mpl_toolkits/axes_grid1/axes_divider.py new file mode 100644 index 0000000000000000000000000000000000000000..50365f482b725d3be435ddd14d9729d30b343124 --- /dev/null +++ b/lib/python3.12/site-packages/mpl_toolkits/axes_grid1/axes_divider.py @@ -0,0 +1,618 @@ +""" +Helper classes to adjust the positions of multiple axes at drawing time. +""" + +import functools + +import numpy as np + +import matplotlib as mpl +from matplotlib import _api +from matplotlib.gridspec import SubplotSpec +import matplotlib.transforms as mtransforms +from . import axes_size as Size + + +class Divider: + """ + An Axes positioning class. + + The divider is initialized with lists of horizontal and vertical sizes + (:mod:`mpl_toolkits.axes_grid1.axes_size`) based on which a given + rectangular area will be divided. + + The `new_locator` method then creates a callable object + that can be used as the *axes_locator* of the axes. + """ + + def __init__(self, fig, pos, horizontal, vertical, + aspect=None, anchor="C"): + """ + Parameters + ---------- + fig : Figure + pos : tuple of 4 floats + Position of the rectangle that will be divided. + horizontal : list of :mod:`~mpl_toolkits.axes_grid1.axes_size` + Sizes for horizontal division. + vertical : list of :mod:`~mpl_toolkits.axes_grid1.axes_size` + Sizes for vertical division. + aspect : bool, optional + Whether overall rectangular area is reduced so that the relative + part of the horizontal and vertical scales have the same scale. + anchor : (float, float) or {'C', 'SW', 'S', 'SE', 'E', 'NE', 'N', \ +'NW', 'W'}, default: 'C' + Placement of the reduced rectangle, when *aspect* is True. + """ + + self._fig = fig + self._pos = pos + self._horizontal = horizontal + self._vertical = vertical + self._anchor = anchor + self.set_anchor(anchor) + self._aspect = aspect + self._xrefindex = 0 + self._yrefindex = 0 + self._locator = None + + def get_horizontal_sizes(self, renderer): + return np.array([s.get_size(renderer) for s in self.get_horizontal()]) + + def get_vertical_sizes(self, renderer): + return np.array([s.get_size(renderer) for s in self.get_vertical()]) + + def set_position(self, pos): + """ + Set the position of the rectangle. + + Parameters + ---------- + pos : tuple of 4 floats + position of the rectangle that will be divided + """ + self._pos = pos + + def get_position(self): + """Return the position of the rectangle.""" + return self._pos + + def set_anchor(self, anchor): + """ + Parameters + ---------- + anchor : (float, float) or {'C', 'SW', 'S', 'SE', 'E', 'NE', 'N', \ +'NW', 'W'} + Either an (*x*, *y*) pair of relative coordinates (0 is left or + bottom, 1 is right or top), 'C' (center), or a cardinal direction + ('SW', southwest, is bottom left, etc.). + + See Also + -------- + .Axes.set_anchor + """ + if isinstance(anchor, str): + _api.check_in_list(mtransforms.Bbox.coefs, anchor=anchor) + elif not isinstance(anchor, (tuple, list)) or len(anchor) != 2: + raise TypeError("anchor must be str or 2-tuple") + self._anchor = anchor + + def get_anchor(self): + """Return the anchor.""" + return self._anchor + + def get_subplotspec(self): + return None + + def set_horizontal(self, h): + """ + Parameters + ---------- + h : list of :mod:`~mpl_toolkits.axes_grid1.axes_size` + sizes for horizontal division + """ + self._horizontal = h + + def get_horizontal(self): + """Return horizontal sizes.""" + return self._horizontal + + def set_vertical(self, v): + """ + Parameters + ---------- + v : list of :mod:`~mpl_toolkits.axes_grid1.axes_size` + sizes for vertical division + """ + self._vertical = v + + def get_vertical(self): + """Return vertical sizes.""" + return self._vertical + + def set_aspect(self, aspect=False): + """ + Parameters + ---------- + aspect : bool + """ + self._aspect = aspect + + def get_aspect(self): + """Return aspect.""" + return self._aspect + + def set_locator(self, _locator): + self._locator = _locator + + def get_locator(self): + return self._locator + + def get_position_runtime(self, ax, renderer): + if self._locator is None: + return self.get_position() + else: + return self._locator(ax, renderer).bounds + + @staticmethod + def _calc_k(sizes, total): + # sizes is a (n, 2) array of (rel_size, abs_size); this method finds + # the k factor such that sum(rel_size * k + abs_size) == total. + rel_sum, abs_sum = sizes.sum(0) + return (total - abs_sum) / rel_sum if rel_sum else 0 + + @staticmethod + def _calc_offsets(sizes, k): + # Apply k factors to (n, 2) sizes array of (rel_size, abs_size); return + # the resulting cumulative offset positions. + return np.cumsum([0, *(sizes @ [k, 1])]) + + def new_locator(self, nx, ny, nx1=None, ny1=None): + """ + Return an axes locator callable for the specified cell. + + Parameters + ---------- + nx, nx1 : int + Integers specifying the column-position of the + cell. When *nx1* is None, a single *nx*-th column is + specified. Otherwise, location of columns spanning between *nx* + to *nx1* (but excluding *nx1*-th column) is specified. + ny, ny1 : int + Same as *nx* and *nx1*, but for row positions. + """ + if nx1 is None: + nx1 = nx + 1 + if ny1 is None: + ny1 = ny + 1 + # append_size("left") adds a new size at the beginning of the + # horizontal size lists; this shift transforms e.g. + # new_locator(nx=2, ...) into effectively new_locator(nx=3, ...). To + # take that into account, instead of recording nx, we record + # nx-self._xrefindex, where _xrefindex is shifted by 1 by each + # append_size("left"), and re-add self._xrefindex back to nx in + # _locate, when the actual axes position is computed. Ditto for y. + xref = self._xrefindex + yref = self._yrefindex + locator = functools.partial( + self._locate, nx - xref, ny - yref, nx1 - xref, ny1 - yref) + locator.get_subplotspec = self.get_subplotspec + return locator + + def _locate(self, nx, ny, nx1, ny1, axes, renderer): + """ + Implementation of ``divider.new_locator().__call__``. + + The axes locator callable returned by ``new_locator()`` is created as + a `functools.partial` of this method with *nx*, *ny*, *nx1*, and *ny1* + specifying the requested cell. + """ + nx += self._xrefindex + nx1 += self._xrefindex + ny += self._yrefindex + ny1 += self._yrefindex + + fig_w, fig_h = self._fig.bbox.size / self._fig.dpi + x, y, w, h = self.get_position_runtime(axes, renderer) + + hsizes = self.get_horizontal_sizes(renderer) + vsizes = self.get_vertical_sizes(renderer) + k_h = self._calc_k(hsizes, fig_w * w) + k_v = self._calc_k(vsizes, fig_h * h) + + if self.get_aspect(): + k = min(k_h, k_v) + ox = self._calc_offsets(hsizes, k) + oy = self._calc_offsets(vsizes, k) + + ww = (ox[-1] - ox[0]) / fig_w + hh = (oy[-1] - oy[0]) / fig_h + pb = mtransforms.Bbox.from_bounds(x, y, w, h) + pb1 = mtransforms.Bbox.from_bounds(x, y, ww, hh) + x0, y0 = pb1.anchored(self.get_anchor(), pb).p0 + + else: + ox = self._calc_offsets(hsizes, k_h) + oy = self._calc_offsets(vsizes, k_v) + x0, y0 = x, y + + if nx1 is None: + nx1 = -1 + if ny1 is None: + ny1 = -1 + + x1, w1 = x0 + ox[nx] / fig_w, (ox[nx1] - ox[nx]) / fig_w + y1, h1 = y0 + oy[ny] / fig_h, (oy[ny1] - oy[ny]) / fig_h + + return mtransforms.Bbox.from_bounds(x1, y1, w1, h1) + + def append_size(self, position, size): + _api.check_in_list(["left", "right", "bottom", "top"], + position=position) + if position == "left": + self._horizontal.insert(0, size) + self._xrefindex += 1 + elif position == "right": + self._horizontal.append(size) + elif position == "bottom": + self._vertical.insert(0, size) + self._yrefindex += 1 + else: # 'top' + self._vertical.append(size) + + def add_auto_adjustable_area(self, use_axes, pad=0.1, adjust_dirs=None): + """ + Add auto-adjustable padding around *use_axes* to take their decorations + (title, labels, ticks, ticklabels) into account during layout. + + Parameters + ---------- + use_axes : `~matplotlib.axes.Axes` or list of `~matplotlib.axes.Axes` + The Axes whose decorations are taken into account. + pad : float, default: 0.1 + Additional padding in inches. + adjust_dirs : list of {"left", "right", "bottom", "top"}, optional + The sides where padding is added; defaults to all four sides. + """ + if adjust_dirs is None: + adjust_dirs = ["left", "right", "bottom", "top"] + for d in adjust_dirs: + self.append_size(d, Size._AxesDecorationsSize(use_axes, d) + pad) + + +class SubplotDivider(Divider): + """ + The Divider class whose rectangle area is specified as a subplot geometry. + """ + + def __init__(self, fig, *args, horizontal=None, vertical=None, + aspect=None, anchor='C'): + """ + Parameters + ---------- + fig : `~matplotlib.figure.Figure` + + *args : tuple (*nrows*, *ncols*, *index*) or int + The array of subplots in the figure has dimensions ``(nrows, + ncols)``, and *index* is the index of the subplot being created. + *index* starts at 1 in the upper left corner and increases to the + right. + + If *nrows*, *ncols*, and *index* are all single digit numbers, then + *args* can be passed as a single 3-digit number (e.g. 234 for + (2, 3, 4)). + horizontal : list of :mod:`~mpl_toolkits.axes_grid1.axes_size`, optional + Sizes for horizontal division. + vertical : list of :mod:`~mpl_toolkits.axes_grid1.axes_size`, optional + Sizes for vertical division. + aspect : bool, optional + Whether overall rectangular area is reduced so that the relative + part of the horizontal and vertical scales have the same scale. + anchor : (float, float) or {'C', 'SW', 'S', 'SE', 'E', 'NE', 'N', \ +'NW', 'W'}, default: 'C' + Placement of the reduced rectangle, when *aspect* is True. + """ + self.figure = fig + super().__init__(fig, [0, 0, 1, 1], + horizontal=horizontal or [], vertical=vertical or [], + aspect=aspect, anchor=anchor) + self.set_subplotspec(SubplotSpec._from_subplot_args(fig, args)) + + def get_position(self): + """Return the bounds of the subplot box.""" + return self.get_subplotspec().get_position(self.figure).bounds + + def get_subplotspec(self): + """Get the SubplotSpec instance.""" + return self._subplotspec + + def set_subplotspec(self, subplotspec): + """Set the SubplotSpec instance.""" + self._subplotspec = subplotspec + self.set_position(subplotspec.get_position(self.figure)) + + +class AxesDivider(Divider): + """ + Divider based on the preexisting axes. + """ + + def __init__(self, axes, xref=None, yref=None): + """ + Parameters + ---------- + axes : :class:`~matplotlib.axes.Axes` + xref + yref + """ + self._axes = axes + if xref is None: + self._xref = Size.AxesX(axes) + else: + self._xref = xref + if yref is None: + self._yref = Size.AxesY(axes) + else: + self._yref = yref + + super().__init__(fig=axes.get_figure(), pos=None, + horizontal=[self._xref], vertical=[self._yref], + aspect=None, anchor="C") + + def _get_new_axes(self, *, axes_class=None, **kwargs): + axes = self._axes + if axes_class is None: + axes_class = type(axes) + return axes_class(axes.get_figure(), axes.get_position(original=True), + **kwargs) + + def new_horizontal(self, size, pad=None, pack_start=False, **kwargs): + """ + Helper method for ``append_axes("left")`` and ``append_axes("right")``. + + See the documentation of `append_axes` for more details. + + :meta private: + """ + if pad is None: + pad = mpl.rcParams["figure.subplot.wspace"] * self._xref + pos = "left" if pack_start else "right" + if pad: + if not isinstance(pad, Size._Base): + pad = Size.from_any(pad, fraction_ref=self._xref) + self.append_size(pos, pad) + if not isinstance(size, Size._Base): + size = Size.from_any(size, fraction_ref=self._xref) + self.append_size(pos, size) + locator = self.new_locator( + nx=0 if pack_start else len(self._horizontal) - 1, + ny=self._yrefindex) + ax = self._get_new_axes(**kwargs) + ax.set_axes_locator(locator) + return ax + + def new_vertical(self, size, pad=None, pack_start=False, **kwargs): + """ + Helper method for ``append_axes("top")`` and ``append_axes("bottom")``. + + See the documentation of `append_axes` for more details. + + :meta private: + """ + if pad is None: + pad = mpl.rcParams["figure.subplot.hspace"] * self._yref + pos = "bottom" if pack_start else "top" + if pad: + if not isinstance(pad, Size._Base): + pad = Size.from_any(pad, fraction_ref=self._yref) + self.append_size(pos, pad) + if not isinstance(size, Size._Base): + size = Size.from_any(size, fraction_ref=self._yref) + self.append_size(pos, size) + locator = self.new_locator( + nx=self._xrefindex, + ny=0 if pack_start else len(self._vertical) - 1) + ax = self._get_new_axes(**kwargs) + ax.set_axes_locator(locator) + return ax + + def append_axes(self, position, size, pad=None, *, axes_class=None, + **kwargs): + """ + Add a new axes on a given side of the main axes. + + Parameters + ---------- + position : {"left", "right", "bottom", "top"} + Where the new axes is positioned relative to the main axes. + size : :mod:`~mpl_toolkits.axes_grid1.axes_size` or float or str + The axes width or height. float or str arguments are interpreted + as ``axes_size.from_any(size, AxesX(<main_axes>))`` for left or + right axes, and likewise with ``AxesY`` for bottom or top axes. + pad : :mod:`~mpl_toolkits.axes_grid1.axes_size` or float or str + Padding between the axes. float or str arguments are interpreted + as for *size*. Defaults to :rc:`figure.subplot.wspace` times the + main Axes width (left or right axes) or :rc:`figure.subplot.hspace` + times the main Axes height (bottom or top axes). + axes_class : subclass type of `~.axes.Axes`, optional + The type of the new axes. Defaults to the type of the main axes. + **kwargs + All extra keywords arguments are passed to the created axes. + """ + create_axes, pack_start = _api.check_getitem({ + "left": (self.new_horizontal, True), + "right": (self.new_horizontal, False), + "bottom": (self.new_vertical, True), + "top": (self.new_vertical, False), + }, position=position) + ax = create_axes( + size, pad, pack_start=pack_start, axes_class=axes_class, **kwargs) + self._fig.add_axes(ax) + return ax + + def get_aspect(self): + if self._aspect is None: + aspect = self._axes.get_aspect() + if aspect == "auto": + return False + else: + return True + else: + return self._aspect + + def get_position(self): + if self._pos is None: + bbox = self._axes.get_position(original=True) + return bbox.bounds + else: + return self._pos + + def get_anchor(self): + if self._anchor is None: + return self._axes.get_anchor() + else: + return self._anchor + + def get_subplotspec(self): + return self._axes.get_subplotspec() + + +# Helper for HBoxDivider/VBoxDivider. +# The variable names are written for a horizontal layout, but the calculations +# work identically for vertical layouts. +def _locate(x, y, w, h, summed_widths, equal_heights, fig_w, fig_h, anchor): + + total_width = fig_w * w + max_height = fig_h * h + + # Determine the k factors. + n = len(equal_heights) + eq_rels, eq_abss = equal_heights.T + sm_rels, sm_abss = summed_widths.T + A = np.diag([*eq_rels, 0]) + A[:n, -1] = -1 + A[-1, :-1] = sm_rels + B = [*(-eq_abss), total_width - sm_abss.sum()] + # A @ K = B: This finds factors {k_0, ..., k_{N-1}, H} so that + # eq_rel_i * k_i + eq_abs_i = H for all i: all axes have the same height + # sum(sm_rel_i * k_i + sm_abs_i) = total_width: fixed total width + # (foo_rel_i * k_i + foo_abs_i will end up being the size of foo.) + *karray, height = np.linalg.solve(A, B) + if height > max_height: # Additionally, upper-bound the height. + karray = (max_height - eq_abss) / eq_rels + + # Compute the offsets corresponding to these factors. + ox = np.cumsum([0, *(sm_rels * karray + sm_abss)]) + ww = (ox[-1] - ox[0]) / fig_w + h0_rel, h0_abs = equal_heights[0] + hh = (karray[0]*h0_rel + h0_abs) / fig_h + pb = mtransforms.Bbox.from_bounds(x, y, w, h) + pb1 = mtransforms.Bbox.from_bounds(x, y, ww, hh) + x0, y0 = pb1.anchored(anchor, pb).p0 + + return x0, y0, ox, hh + + +class HBoxDivider(SubplotDivider): + """ + A `.SubplotDivider` for laying out axes horizontally, while ensuring that + they have equal heights. + + Examples + -------- + .. plot:: gallery/axes_grid1/demo_axes_hbox_divider.py + """ + + def new_locator(self, nx, nx1=None): + """ + Create an axes locator callable for the specified cell. + + Parameters + ---------- + nx, nx1 : int + Integers specifying the column-position of the + cell. When *nx1* is None, a single *nx*-th column is + specified. Otherwise, location of columns spanning between *nx* + to *nx1* (but excluding *nx1*-th column) is specified. + """ + return super().new_locator(nx, 0, nx1, 0) + + def _locate(self, nx, ny, nx1, ny1, axes, renderer): + # docstring inherited + nx += self._xrefindex + nx1 += self._xrefindex + fig_w, fig_h = self._fig.bbox.size / self._fig.dpi + x, y, w, h = self.get_position_runtime(axes, renderer) + summed_ws = self.get_horizontal_sizes(renderer) + equal_hs = self.get_vertical_sizes(renderer) + x0, y0, ox, hh = _locate( + x, y, w, h, summed_ws, equal_hs, fig_w, fig_h, self.get_anchor()) + if nx1 is None: + nx1 = -1 + x1, w1 = x0 + ox[nx] / fig_w, (ox[nx1] - ox[nx]) / fig_w + y1, h1 = y0, hh + return mtransforms.Bbox.from_bounds(x1, y1, w1, h1) + + +class VBoxDivider(SubplotDivider): + """ + A `.SubplotDivider` for laying out axes vertically, while ensuring that + they have equal widths. + """ + + def new_locator(self, ny, ny1=None): + """ + Create an axes locator callable for the specified cell. + + Parameters + ---------- + ny, ny1 : int + Integers specifying the row-position of the + cell. When *ny1* is None, a single *ny*-th row is + specified. Otherwise, location of rows spanning between *ny* + to *ny1* (but excluding *ny1*-th row) is specified. + """ + return super().new_locator(0, ny, 0, ny1) + + def _locate(self, nx, ny, nx1, ny1, axes, renderer): + # docstring inherited + ny += self._yrefindex + ny1 += self._yrefindex + fig_w, fig_h = self._fig.bbox.size / self._fig.dpi + x, y, w, h = self.get_position_runtime(axes, renderer) + summed_hs = self.get_vertical_sizes(renderer) + equal_ws = self.get_horizontal_sizes(renderer) + y0, x0, oy, ww = _locate( + y, x, h, w, summed_hs, equal_ws, fig_h, fig_w, self.get_anchor()) + if ny1 is None: + ny1 = -1 + x1, w1 = x0, ww + y1, h1 = y0 + oy[ny] / fig_h, (oy[ny1] - oy[ny]) / fig_h + return mtransforms.Bbox.from_bounds(x1, y1, w1, h1) + + +def make_axes_locatable(axes): + divider = AxesDivider(axes) + locator = divider.new_locator(nx=0, ny=0) + axes.set_axes_locator(locator) + + return divider + + +def make_axes_area_auto_adjustable( + ax, use_axes=None, pad=0.1, adjust_dirs=None): + """ + Add auto-adjustable padding around *ax* to take its decorations (title, + labels, ticks, ticklabels) into account during layout, using + `.Divider.add_auto_adjustable_area`. + + By default, padding is determined from the decorations of *ax*. + Pass *use_axes* to consider the decorations of other Axes instead. + """ + if adjust_dirs is None: + adjust_dirs = ["left", "right", "bottom", "top"] + divider = make_axes_locatable(ax) + if use_axes is None: + use_axes = ax + divider.add_auto_adjustable_area(use_axes=use_axes, pad=pad, + adjust_dirs=adjust_dirs) diff --git a/lib/python3.12/site-packages/mpl_toolkits/axes_grid1/axes_grid.py b/lib/python3.12/site-packages/mpl_toolkits/axes_grid1/axes_grid.py new file mode 100644 index 0000000000000000000000000000000000000000..20abf18ea79c32ccdfe678d81ad975e83fe4bc0b --- /dev/null +++ b/lib/python3.12/site-packages/mpl_toolkits/axes_grid1/axes_grid.py @@ -0,0 +1,563 @@ +from numbers import Number +import functools +from types import MethodType + +import numpy as np + +from matplotlib import _api, cbook +from matplotlib.gridspec import SubplotSpec + +from .axes_divider import Size, SubplotDivider, Divider +from .mpl_axes import Axes, SimpleAxisArtist + + +class CbarAxesBase: + def __init__(self, *args, orientation, **kwargs): + self.orientation = orientation + super().__init__(*args, **kwargs) + + def colorbar(self, mappable, **kwargs): + return self.get_figure(root=False).colorbar( + mappable, cax=self, location=self.orientation, **kwargs) + + +_cbaraxes_class_factory = cbook._make_class_factory(CbarAxesBase, "Cbar{}") + + +class Grid: + """ + A grid of Axes. + + In Matplotlib, the Axes location (and size) is specified in normalized + figure coordinates. This may not be ideal for images that needs to be + displayed with a given aspect ratio; for example, it is difficult to + display multiple images of a same size with some fixed padding between + them. AxesGrid can be used in such case. + + Attributes + ---------- + axes_all : list of Axes + A flat list of Axes. Note that you can also access this directly + from the grid. The following is equivalent :: + + grid[i] == grid.axes_all[i] + len(grid) == len(grid.axes_all) + + axes_column : list of list of Axes + A 2D list of Axes where the first index is the column. This results + in the usage pattern ``grid.axes_column[col][row]``. + axes_row : list of list of Axes + A 2D list of Axes where the first index is the row. This results + in the usage pattern ``grid.axes_row[row][col]``. + axes_llc : Axes + The Axes in the lower left corner. + ngrids : int + Number of Axes in the grid. + """ + + _defaultAxesClass = Axes + + def __init__(self, fig, + rect, + nrows_ncols, + ngrids=None, + direction="row", + axes_pad=0.02, + *, + share_all=False, + share_x=True, + share_y=True, + label_mode="L", + axes_class=None, + aspect=False, + ): + """ + Parameters + ---------- + fig : `.Figure` + The parent figure. + rect : (float, float, float, float), (int, int, int), int, or \ + `~.SubplotSpec` + The axes position, as a ``(left, bottom, width, height)`` tuple, + as a three-digit subplot position code (e.g., ``(1, 2, 1)`` or + ``121``), or as a `~.SubplotSpec`. + nrows_ncols : (int, int) + Number of rows and columns in the grid. + ngrids : int or None, default: None + If not None, only the first *ngrids* axes in the grid are created. + direction : {"row", "column"}, default: "row" + Whether axes are created in row-major ("row by row") or + column-major order ("column by column"). This also affects the + order in which axes are accessed using indexing (``grid[index]``). + axes_pad : float or (float, float), default: 0.02 + Padding or (horizontal padding, vertical padding) between axes, in + inches. + share_all : bool, default: False + Whether all axes share their x- and y-axis. Overrides *share_x* + and *share_y*. + share_x : bool, default: True + Whether all axes of a column share their x-axis. + share_y : bool, default: True + Whether all axes of a row share their y-axis. + label_mode : {"L", "1", "all", "keep"}, default: "L" + Determines which axes will get tick labels: + + - "L": All axes on the left column get vertical tick labels; + all axes on the bottom row get horizontal tick labels. + - "1": Only the bottom left axes is labelled. + - "all": All axes are labelled. + - "keep": Do not do anything. + + axes_class : subclass of `matplotlib.axes.Axes`, default: `.mpl_axes.Axes` + The type of Axes to create. + aspect : bool, default: False + Whether the axes aspect ratio follows the aspect ratio of the data + limits. + """ + self._nrows, self._ncols = nrows_ncols + + if ngrids is None: + ngrids = self._nrows * self._ncols + else: + if not 0 < ngrids <= self._nrows * self._ncols: + raise ValueError( + "ngrids must be positive and not larger than nrows*ncols") + + self.ngrids = ngrids + + self._horiz_pad_size, self._vert_pad_size = map( + Size.Fixed, np.broadcast_to(axes_pad, 2)) + + _api.check_in_list(["column", "row"], direction=direction) + self._direction = direction + + if axes_class is None: + axes_class = self._defaultAxesClass + elif isinstance(axes_class, (list, tuple)): + cls, kwargs = axes_class + axes_class = functools.partial(cls, **kwargs) + + kw = dict(horizontal=[], vertical=[], aspect=aspect) + if isinstance(rect, (Number, SubplotSpec)): + self._divider = SubplotDivider(fig, rect, **kw) + elif len(rect) == 3: + self._divider = SubplotDivider(fig, *rect, **kw) + elif len(rect) == 4: + self._divider = Divider(fig, rect, **kw) + else: + raise TypeError("Incorrect rect format") + + rect = self._divider.get_position() + + axes_array = np.full((self._nrows, self._ncols), None, dtype=object) + for i in range(self.ngrids): + col, row = self._get_col_row(i) + if share_all: + sharex = sharey = axes_array[0, 0] + else: + sharex = axes_array[0, col] if share_x else None + sharey = axes_array[row, 0] if share_y else None + axes_array[row, col] = axes_class( + fig, rect, sharex=sharex, sharey=sharey) + self.axes_all = axes_array.ravel( + order="C" if self._direction == "row" else "F").tolist() + self.axes_column = axes_array.T.tolist() + self.axes_row = axes_array.tolist() + self.axes_llc = self.axes_column[0][-1] + + self._init_locators() + + for ax in self.axes_all: + fig.add_axes(ax) + + self.set_label_mode(label_mode) + + def _init_locators(self): + self._divider.set_horizontal( + [Size.Scaled(1), self._horiz_pad_size] * (self._ncols-1) + [Size.Scaled(1)]) + self._divider.set_vertical( + [Size.Scaled(1), self._vert_pad_size] * (self._nrows-1) + [Size.Scaled(1)]) + for i in range(self.ngrids): + col, row = self._get_col_row(i) + self.axes_all[i].set_axes_locator( + self._divider.new_locator(nx=2 * col, ny=2 * (self._nrows - 1 - row))) + + def _get_col_row(self, n): + if self._direction == "column": + col, row = divmod(n, self._nrows) + else: + row, col = divmod(n, self._ncols) + + return col, row + + # Good to propagate __len__ if we have __getitem__ + def __len__(self): + return len(self.axes_all) + + def __getitem__(self, i): + return self.axes_all[i] + + def get_geometry(self): + """ + Return the number of rows and columns of the grid as (nrows, ncols). + """ + return self._nrows, self._ncols + + def set_axes_pad(self, axes_pad): + """ + Set the padding between the axes. + + Parameters + ---------- + axes_pad : (float, float) + The padding (horizontal pad, vertical pad) in inches. + """ + self._horiz_pad_size.fixed_size = axes_pad[0] + self._vert_pad_size.fixed_size = axes_pad[1] + + def get_axes_pad(self): + """ + Return the axes padding. + + Returns + ------- + hpad, vpad + Padding (horizontal pad, vertical pad) in inches. + """ + return (self._horiz_pad_size.fixed_size, + self._vert_pad_size.fixed_size) + + def set_aspect(self, aspect): + """Set the aspect of the SubplotDivider.""" + self._divider.set_aspect(aspect) + + def get_aspect(self): + """Return the aspect of the SubplotDivider.""" + return self._divider.get_aspect() + + def set_label_mode(self, mode): + """ + Define which axes have tick labels. + + Parameters + ---------- + mode : {"L", "1", "all", "keep"} + The label mode: + + - "L": All axes on the left column get vertical tick labels; + all axes on the bottom row get horizontal tick labels. + - "1": Only the bottom left axes is labelled. + - "all": All axes are labelled. + - "keep": Do not do anything. + """ + _api.check_in_list(["all", "L", "1", "keep"], mode=mode) + is_last_row, is_first_col = ( + np.mgrid[:self._nrows, :self._ncols] == [[[self._nrows - 1]], [[0]]]) + if mode == "all": + bottom = left = np.full((self._nrows, self._ncols), True) + elif mode == "L": + bottom = is_last_row + left = is_first_col + elif mode == "1": + bottom = left = is_last_row & is_first_col + else: + return + for i in range(self._nrows): + for j in range(self._ncols): + ax = self.axes_row[i][j] + if isinstance(ax.axis, MethodType): + bottom_axis = SimpleAxisArtist(ax.xaxis, 1, ax.spines["bottom"]) + left_axis = SimpleAxisArtist(ax.yaxis, 1, ax.spines["left"]) + else: + bottom_axis = ax.axis["bottom"] + left_axis = ax.axis["left"] + bottom_axis.toggle(ticklabels=bottom[i, j], label=bottom[i, j]) + left_axis.toggle(ticklabels=left[i, j], label=left[i, j]) + + def get_divider(self): + return self._divider + + def set_axes_locator(self, locator): + self._divider.set_locator(locator) + + def get_axes_locator(self): + return self._divider.get_locator() + + +class ImageGrid(Grid): + """ + A grid of Axes for Image display. + + This class is a specialization of `~.axes_grid1.axes_grid.Grid` for displaying a + grid of images. In particular, it forces all axes in a column to share their x-axis + and all axes in a row to share their y-axis. It further provides helpers to add + colorbars to some or all axes. + """ + + def __init__(self, fig, + rect, + nrows_ncols, + ngrids=None, + direction="row", + axes_pad=0.02, + *, + share_all=False, + aspect=True, + label_mode="L", + cbar_mode=None, + cbar_location="right", + cbar_pad=None, + cbar_size="5%", + cbar_set_cax=True, + axes_class=None, + ): + """ + Parameters + ---------- + fig : `.Figure` + The parent figure. + rect : (float, float, float, float) or int + The axes position, as a ``(left, bottom, width, height)`` tuple or + as a three-digit subplot position code (e.g., "121"). + nrows_ncols : (int, int) + Number of rows and columns in the grid. + ngrids : int or None, default: None + If not None, only the first *ngrids* axes in the grid are created. + direction : {"row", "column"}, default: "row" + Whether axes are created in row-major ("row by row") or + column-major order ("column by column"). This also affects the + order in which axes are accessed using indexing (``grid[index]``). + axes_pad : float or (float, float), default: 0.02in + Padding or (horizontal padding, vertical padding) between axes, in + inches. + share_all : bool, default: False + Whether all axes share their x- and y-axis. Note that in any case, + all axes in a column share their x-axis and all axes in a row share + their y-axis. + aspect : bool, default: True + Whether the axes aspect ratio follows the aspect ratio of the data + limits. + label_mode : {"L", "1", "all"}, default: "L" + Determines which axes will get tick labels: + + - "L": All axes on the left column get vertical tick labels; + all axes on the bottom row get horizontal tick labels. + - "1": Only the bottom left axes is labelled. + - "all": all axes are labelled. + + cbar_mode : {"each", "single", "edge", None}, default: None + Whether to create a colorbar for "each" axes, a "single" colorbar + for the entire grid, colorbars only for axes on the "edge" + determined by *cbar_location*, or no colorbars. The colorbars are + stored in the :attr:`cbar_axes` attribute. + cbar_location : {"left", "right", "bottom", "top"}, default: "right" + cbar_pad : float, default: None + Padding between the image axes and the colorbar axes. + + .. versionchanged:: 3.10 + ``cbar_mode="single"`` no longer adds *axes_pad* between the axes + and the colorbar if the *cbar_location* is "left" or "bottom". + + cbar_size : size specification (see `.Size.from_any`), default: "5%" + Colorbar size. + cbar_set_cax : bool, default: True + If True, each axes in the grid has a *cax* attribute that is bound + to associated *cbar_axes*. + axes_class : subclass of `matplotlib.axes.Axes`, default: None + """ + _api.check_in_list(["each", "single", "edge", None], + cbar_mode=cbar_mode) + _api.check_in_list(["left", "right", "bottom", "top"], + cbar_location=cbar_location) + self._colorbar_mode = cbar_mode + self._colorbar_location = cbar_location + self._colorbar_pad = cbar_pad + self._colorbar_size = cbar_size + # The colorbar axes are created in _init_locators(). + + super().__init__( + fig, rect, nrows_ncols, ngrids, + direction=direction, axes_pad=axes_pad, + share_all=share_all, share_x=True, share_y=True, aspect=aspect, + label_mode=label_mode, axes_class=axes_class) + + for ax in self.cbar_axes: + fig.add_axes(ax) + + if cbar_set_cax: + if self._colorbar_mode == "single": + for ax in self.axes_all: + ax.cax = self.cbar_axes[0] + elif self._colorbar_mode == "edge": + for index, ax in enumerate(self.axes_all): + col, row = self._get_col_row(index) + if self._colorbar_location in ("left", "right"): + ax.cax = self.cbar_axes[row] + else: + ax.cax = self.cbar_axes[col] + else: + for ax, cax in zip(self.axes_all, self.cbar_axes): + ax.cax = cax + + def _init_locators(self): + # Slightly abusing this method to inject colorbar creation into init. + + if self._colorbar_pad is None: + # horizontal or vertical arrangement? + if self._colorbar_location in ("left", "right"): + self._colorbar_pad = self._horiz_pad_size.fixed_size + else: + self._colorbar_pad = self._vert_pad_size.fixed_size + self.cbar_axes = [ + _cbaraxes_class_factory(self._defaultAxesClass)( + self.axes_all[0].get_figure(root=False), self._divider.get_position(), + orientation=self._colorbar_location) + for _ in range(self.ngrids)] + + cb_mode = self._colorbar_mode + cb_location = self._colorbar_location + + h = [] + v = [] + + h_ax_pos = [] + h_cb_pos = [] + if cb_mode == "single" and cb_location in ("left", "bottom"): + if cb_location == "left": + sz = self._nrows * Size.AxesX(self.axes_llc) + h.append(Size.from_any(self._colorbar_size, sz)) + h.append(Size.from_any(self._colorbar_pad, sz)) + locator = self._divider.new_locator(nx=0, ny=0, ny1=-1) + elif cb_location == "bottom": + sz = self._ncols * Size.AxesY(self.axes_llc) + v.append(Size.from_any(self._colorbar_size, sz)) + v.append(Size.from_any(self._colorbar_pad, sz)) + locator = self._divider.new_locator(nx=0, nx1=-1, ny=0) + for i in range(self.ngrids): + self.cbar_axes[i].set_visible(False) + self.cbar_axes[0].set_axes_locator(locator) + self.cbar_axes[0].set_visible(True) + + for col, ax in enumerate(self.axes_row[0]): + if col != 0: + h.append(self._horiz_pad_size) + + if ax: + sz = Size.AxesX(ax, aspect="axes", ref_ax=self.axes_all[0]) + else: + sz = Size.AxesX(self.axes_all[0], + aspect="axes", ref_ax=self.axes_all[0]) + + if (cb_location == "left" + and (cb_mode == "each" + or (cb_mode == "edge" and col == 0))): + h_cb_pos.append(len(h)) + h.append(Size.from_any(self._colorbar_size, sz)) + h.append(Size.from_any(self._colorbar_pad, sz)) + + h_ax_pos.append(len(h)) + h.append(sz) + + if (cb_location == "right" + and (cb_mode == "each" + or (cb_mode == "edge" and col == self._ncols - 1))): + h.append(Size.from_any(self._colorbar_pad, sz)) + h_cb_pos.append(len(h)) + h.append(Size.from_any(self._colorbar_size, sz)) + + v_ax_pos = [] + v_cb_pos = [] + for row, ax in enumerate(self.axes_column[0][::-1]): + if row != 0: + v.append(self._vert_pad_size) + + if ax: + sz = Size.AxesY(ax, aspect="axes", ref_ax=self.axes_all[0]) + else: + sz = Size.AxesY(self.axes_all[0], + aspect="axes", ref_ax=self.axes_all[0]) + + if (cb_location == "bottom" + and (cb_mode == "each" + or (cb_mode == "edge" and row == 0))): + v_cb_pos.append(len(v)) + v.append(Size.from_any(self._colorbar_size, sz)) + v.append(Size.from_any(self._colorbar_pad, sz)) + + v_ax_pos.append(len(v)) + v.append(sz) + + if (cb_location == "top" + and (cb_mode == "each" + or (cb_mode == "edge" and row == self._nrows - 1))): + v.append(Size.from_any(self._colorbar_pad, sz)) + v_cb_pos.append(len(v)) + v.append(Size.from_any(self._colorbar_size, sz)) + + for i in range(self.ngrids): + col, row = self._get_col_row(i) + locator = self._divider.new_locator(nx=h_ax_pos[col], + ny=v_ax_pos[self._nrows-1-row]) + self.axes_all[i].set_axes_locator(locator) + + if cb_mode == "each": + if cb_location in ("right", "left"): + locator = self._divider.new_locator( + nx=h_cb_pos[col], ny=v_ax_pos[self._nrows - 1 - row]) + + elif cb_location in ("top", "bottom"): + locator = self._divider.new_locator( + nx=h_ax_pos[col], ny=v_cb_pos[self._nrows - 1 - row]) + + self.cbar_axes[i].set_axes_locator(locator) + elif cb_mode == "edge": + if (cb_location == "left" and col == 0 + or cb_location == "right" and col == self._ncols - 1): + locator = self._divider.new_locator( + nx=h_cb_pos[0], ny=v_ax_pos[self._nrows - 1 - row]) + self.cbar_axes[row].set_axes_locator(locator) + elif (cb_location == "bottom" and row == self._nrows - 1 + or cb_location == "top" and row == 0): + locator = self._divider.new_locator(nx=h_ax_pos[col], + ny=v_cb_pos[0]) + self.cbar_axes[col].set_axes_locator(locator) + + if cb_mode == "single": + if cb_location == "right": + sz = self._nrows * Size.AxesX(self.axes_llc) + h.append(Size.from_any(self._colorbar_pad, sz)) + h.append(Size.from_any(self._colorbar_size, sz)) + locator = self._divider.new_locator(nx=-2, ny=0, ny1=-1) + elif cb_location == "top": + sz = self._ncols * Size.AxesY(self.axes_llc) + v.append(Size.from_any(self._colorbar_pad, sz)) + v.append(Size.from_any(self._colorbar_size, sz)) + locator = self._divider.new_locator(nx=0, nx1=-1, ny=-2) + if cb_location in ("right", "top"): + for i in range(self.ngrids): + self.cbar_axes[i].set_visible(False) + self.cbar_axes[0].set_axes_locator(locator) + self.cbar_axes[0].set_visible(True) + elif cb_mode == "each": + for i in range(self.ngrids): + self.cbar_axes[i].set_visible(True) + elif cb_mode == "edge": + if cb_location in ("right", "left"): + count = self._nrows + else: + count = self._ncols + for i in range(count): + self.cbar_axes[i].set_visible(True) + for j in range(i + 1, self.ngrids): + self.cbar_axes[j].set_visible(False) + else: + for i in range(self.ngrids): + self.cbar_axes[i].set_visible(False) + self.cbar_axes[i].set_position([1., 1., 0.001, 0.001], + which="active") + + self._divider.set_horizontal(h) + self._divider.set_vertical(v) + + +AxesGrid = ImageGrid diff --git a/lib/python3.12/site-packages/mpl_toolkits/axes_grid1/axes_rgb.py b/lib/python3.12/site-packages/mpl_toolkits/axes_grid1/axes_rgb.py new file mode 100644 index 0000000000000000000000000000000000000000..52fd707e87043eb17929a84c53da2741722ce002 --- /dev/null +++ b/lib/python3.12/site-packages/mpl_toolkits/axes_grid1/axes_rgb.py @@ -0,0 +1,157 @@ +from types import MethodType + +import numpy as np + +from .axes_divider import make_axes_locatable, Size +from .mpl_axes import Axes, SimpleAxisArtist + + +def make_rgb_axes(ax, pad=0.01, axes_class=None, **kwargs): + """ + Parameters + ---------- + ax : `~matplotlib.axes.Axes` + Axes instance to create the RGB Axes in. + pad : float, optional + Fraction of the Axes height to pad. + axes_class : `matplotlib.axes.Axes` or None, optional + Axes class to use for the R, G, and B Axes. If None, use + the same class as *ax*. + **kwargs + Forwarded to *axes_class* init for the R, G, and B Axes. + """ + + divider = make_axes_locatable(ax) + + pad_size = pad * Size.AxesY(ax) + + xsize = ((1-2*pad)/3) * Size.AxesX(ax) + ysize = ((1-2*pad)/3) * Size.AxesY(ax) + + divider.set_horizontal([Size.AxesX(ax), pad_size, xsize]) + divider.set_vertical([ysize, pad_size, ysize, pad_size, ysize]) + + ax.set_axes_locator(divider.new_locator(0, 0, ny1=-1)) + + ax_rgb = [] + if axes_class is None: + axes_class = type(ax) + + for ny in [4, 2, 0]: + ax1 = axes_class(ax.get_figure(), ax.get_position(original=True), + sharex=ax, sharey=ax, **kwargs) + locator = divider.new_locator(nx=2, ny=ny) + ax1.set_axes_locator(locator) + for t in ax1.yaxis.get_ticklabels() + ax1.xaxis.get_ticklabels(): + t.set_visible(False) + try: + for axis in ax1.axis.values(): + axis.major_ticklabels.set_visible(False) + except AttributeError: + pass + + ax_rgb.append(ax1) + + fig = ax.get_figure() + for ax1 in ax_rgb: + fig.add_axes(ax1) + + return ax_rgb + + +class RGBAxes: + """ + 4-panel `~.Axes.imshow` (RGB, R, G, B). + + Layout:: + + ┌───────────────┬─────┐ + │ │ R │ + │ ├─────┤ + │ RGB │ G │ + │ ├─────┤ + │ │ B │ + └───────────────┴─────┘ + + Subclasses can override the ``_defaultAxesClass`` attribute. + By default RGBAxes uses `.mpl_axes.Axes`. + + Attributes + ---------- + RGB : ``_defaultAxesClass`` + The Axes object for the three-channel `~.Axes.imshow`. + R : ``_defaultAxesClass`` + The Axes object for the red channel `~.Axes.imshow`. + G : ``_defaultAxesClass`` + The Axes object for the green channel `~.Axes.imshow`. + B : ``_defaultAxesClass`` + The Axes object for the blue channel `~.Axes.imshow`. + """ + + _defaultAxesClass = Axes + + def __init__(self, *args, pad=0, **kwargs): + """ + Parameters + ---------- + pad : float, default: 0 + Fraction of the Axes height to put as padding. + axes_class : `~matplotlib.axes.Axes` + Axes class to use. If not provided, ``_defaultAxesClass`` is used. + *args + Forwarded to *axes_class* init for the RGB Axes + **kwargs + Forwarded to *axes_class* init for the RGB, R, G, and B Axes + """ + axes_class = kwargs.pop("axes_class", self._defaultAxesClass) + self.RGB = ax = axes_class(*args, **kwargs) + ax.get_figure().add_axes(ax) + self.R, self.G, self.B = make_rgb_axes( + ax, pad=pad, axes_class=axes_class, **kwargs) + # Set the line color and ticks for the axes. + for ax1 in [self.RGB, self.R, self.G, self.B]: + if isinstance(ax1.axis, MethodType): + ad = Axes.AxisDict(self) + ad.update( + bottom=SimpleAxisArtist(ax1.xaxis, 1, ax1.spines["bottom"]), + top=SimpleAxisArtist(ax1.xaxis, 2, ax1.spines["top"]), + left=SimpleAxisArtist(ax1.yaxis, 1, ax1.spines["left"]), + right=SimpleAxisArtist(ax1.yaxis, 2, ax1.spines["right"])) + else: + ad = ax1.axis + ad[:].line.set_color("w") + ad[:].major_ticks.set_markeredgecolor("w") + + def imshow_rgb(self, r, g, b, **kwargs): + """ + Create the four images {rgb, r, g, b}. + + Parameters + ---------- + r, g, b : array-like + The red, green, and blue arrays. + **kwargs + Forwarded to `~.Axes.imshow` calls for the four images. + + Returns + ------- + rgb : `~matplotlib.image.AxesImage` + r : `~matplotlib.image.AxesImage` + g : `~matplotlib.image.AxesImage` + b : `~matplotlib.image.AxesImage` + """ + if not (r.shape == g.shape == b.shape): + raise ValueError( + f'Input shapes ({r.shape}, {g.shape}, {b.shape}) do not match') + RGB = np.dstack([r, g, b]) + R = np.zeros_like(RGB) + R[:, :, 0] = r + G = np.zeros_like(RGB) + G[:, :, 1] = g + B = np.zeros_like(RGB) + B[:, :, 2] = b + im_rgb = self.RGB.imshow(RGB, **kwargs) + im_r = self.R.imshow(R, **kwargs) + im_g = self.G.imshow(G, **kwargs) + im_b = self.B.imshow(B, **kwargs) + return im_rgb, im_r, im_g, im_b diff --git a/lib/python3.12/site-packages/mpl_toolkits/axes_grid1/axes_size.py b/lib/python3.12/site-packages/mpl_toolkits/axes_grid1/axes_size.py new file mode 100644 index 0000000000000000000000000000000000000000..86e5f70d9824392a8c49bce9a18aaf45cf6f0232 --- /dev/null +++ b/lib/python3.12/site-packages/mpl_toolkits/axes_grid1/axes_size.py @@ -0,0 +1,271 @@ +""" +Provides classes of simple units that will be used with `.AxesDivider` +class (or others) to determine the size of each Axes. The unit +classes define `get_size` method that returns a tuple of two floats, +meaning relative and absolute sizes, respectively. + +Note that this class is nothing more than a simple tuple of two +floats. Take a look at the Divider class to see how these two +values are used. + +Once created, the unit classes can be modified by simple arithmetic +operations: addition /subtraction with another unit type or a real number and scaling +(multiplication or division) by a real number. +""" + +from numbers import Real + +from matplotlib import _api +from matplotlib.axes import Axes + + +class _Base: + def __rmul__(self, other): + return self * other + + def __mul__(self, other): + if not isinstance(other, Real): + return NotImplemented + return Fraction(other, self) + + def __div__(self, other): + return (1 / other) * self + + def __add__(self, other): + if isinstance(other, _Base): + return Add(self, other) + else: + return Add(self, Fixed(other)) + + def __neg__(self): + return -1 * self + + def __radd__(self, other): + # other cannot be a _Base instance, because A + B would trigger + # A.__add__(B) first. + return Add(self, Fixed(other)) + + def __sub__(self, other): + return self + (-other) + + def get_size(self, renderer): + """ + Return two-float tuple with relative and absolute sizes. + """ + raise NotImplementedError("Subclasses must implement") + + +class Add(_Base): + """ + Sum of two sizes. + """ + + def __init__(self, a, b): + self._a = a + self._b = b + + def get_size(self, renderer): + a_rel_size, a_abs_size = self._a.get_size(renderer) + b_rel_size, b_abs_size = self._b.get_size(renderer) + return a_rel_size + b_rel_size, a_abs_size + b_abs_size + + +class Fixed(_Base): + """ + Simple fixed size with absolute part = *fixed_size* and relative part = 0. + """ + + def __init__(self, fixed_size): + _api.check_isinstance(Real, fixed_size=fixed_size) + self.fixed_size = fixed_size + + def get_size(self, renderer): + rel_size = 0. + abs_size = self.fixed_size + return rel_size, abs_size + + +class Scaled(_Base): + """ + Simple scaled(?) size with absolute part = 0 and + relative part = *scalable_size*. + """ + + def __init__(self, scalable_size): + self._scalable_size = scalable_size + + def get_size(self, renderer): + rel_size = self._scalable_size + abs_size = 0. + return rel_size, abs_size + +Scalable = Scaled + + +def _get_axes_aspect(ax): + aspect = ax.get_aspect() + if aspect == "auto": + aspect = 1. + return aspect + + +class AxesX(_Base): + """ + Scaled size whose relative part corresponds to the data width + of the *axes* multiplied by the *aspect*. + """ + + def __init__(self, axes, aspect=1., ref_ax=None): + self._axes = axes + self._aspect = aspect + if aspect == "axes" and ref_ax is None: + raise ValueError("ref_ax must be set when aspect='axes'") + self._ref_ax = ref_ax + + def get_size(self, renderer): + l1, l2 = self._axes.get_xlim() + if self._aspect == "axes": + ref_aspect = _get_axes_aspect(self._ref_ax) + aspect = ref_aspect / _get_axes_aspect(self._axes) + else: + aspect = self._aspect + + rel_size = abs(l2-l1)*aspect + abs_size = 0. + return rel_size, abs_size + + +class AxesY(_Base): + """ + Scaled size whose relative part corresponds to the data height + of the *axes* multiplied by the *aspect*. + """ + + def __init__(self, axes, aspect=1., ref_ax=None): + self._axes = axes + self._aspect = aspect + if aspect == "axes" and ref_ax is None: + raise ValueError("ref_ax must be set when aspect='axes'") + self._ref_ax = ref_ax + + def get_size(self, renderer): + l1, l2 = self._axes.get_ylim() + + if self._aspect == "axes": + ref_aspect = _get_axes_aspect(self._ref_ax) + aspect = _get_axes_aspect(self._axes) + else: + aspect = self._aspect + + rel_size = abs(l2-l1)*aspect + abs_size = 0. + return rel_size, abs_size + + +class MaxExtent(_Base): + """ + Size whose absolute part is either the largest width or the largest height + of the given *artist_list*. + """ + + def __init__(self, artist_list, w_or_h): + self._artist_list = artist_list + _api.check_in_list(["width", "height"], w_or_h=w_or_h) + self._w_or_h = w_or_h + + def add_artist(self, a): + self._artist_list.append(a) + + def get_size(self, renderer): + rel_size = 0. + extent_list = [ + getattr(a.get_window_extent(renderer), self._w_or_h) / a.figure.dpi + for a in self._artist_list] + abs_size = max(extent_list, default=0) + return rel_size, abs_size + + +class MaxWidth(MaxExtent): + """ + Size whose absolute part is the largest width of the given *artist_list*. + """ + + def __init__(self, artist_list): + super().__init__(artist_list, "width") + + +class MaxHeight(MaxExtent): + """ + Size whose absolute part is the largest height of the given *artist_list*. + """ + + def __init__(self, artist_list): + super().__init__(artist_list, "height") + + +class Fraction(_Base): + """ + An instance whose size is a *fraction* of the *ref_size*. + + >>> s = Fraction(0.3, AxesX(ax)) + """ + + def __init__(self, fraction, ref_size): + _api.check_isinstance(Real, fraction=fraction) + self._fraction_ref = ref_size + self._fraction = fraction + + def get_size(self, renderer): + if self._fraction_ref is None: + return self._fraction, 0. + else: + r, a = self._fraction_ref.get_size(renderer) + rel_size = r*self._fraction + abs_size = a*self._fraction + return rel_size, abs_size + + +def from_any(size, fraction_ref=None): + """ + Create a Fixed unit when the first argument is a float, or a + Fraction unit if that is a string that ends with %. The second + argument is only meaningful when Fraction unit is created. + + >>> from mpl_toolkits.axes_grid1.axes_size import from_any + >>> a = from_any(1.2) # => Fixed(1.2) + >>> from_any("50%", a) # => Fraction(0.5, a) + """ + if isinstance(size, Real): + return Fixed(size) + elif isinstance(size, str): + if size[-1] == "%": + return Fraction(float(size[:-1]) / 100, fraction_ref) + raise ValueError("Unknown format") + + +class _AxesDecorationsSize(_Base): + """ + Fixed size, corresponding to the size of decorations on a given Axes side. + """ + + _get_size_map = { + "left": lambda tight_bb, axes_bb: axes_bb.xmin - tight_bb.xmin, + "right": lambda tight_bb, axes_bb: tight_bb.xmax - axes_bb.xmax, + "bottom": lambda tight_bb, axes_bb: axes_bb.ymin - tight_bb.ymin, + "top": lambda tight_bb, axes_bb: tight_bb.ymax - axes_bb.ymax, + } + + def __init__(self, ax, direction): + _api.check_in_list(self._get_size_map, direction=direction) + self._direction = direction + self._ax_list = [ax] if isinstance(ax, Axes) else ax + + def get_size(self, renderer): + sz = max([ + self._get_size_map[self._direction]( + ax.get_tightbbox(renderer, call_axes_locator=False), ax.bbox) + for ax in self._ax_list]) + dpi = renderer.points_to_pixels(72) + abs_size = sz / dpi + rel_size = 0 + return rel_size, abs_size diff --git a/lib/python3.12/site-packages/mpl_toolkits/axes_grid1/inset_locator.py b/lib/python3.12/site-packages/mpl_toolkits/axes_grid1/inset_locator.py new file mode 100644 index 0000000000000000000000000000000000000000..52fe6efc061856bd33939c31efe404f619faf033 --- /dev/null +++ b/lib/python3.12/site-packages/mpl_toolkits/axes_grid1/inset_locator.py @@ -0,0 +1,519 @@ +""" +A collection of functions and objects for creating or placing inset axes. +""" + +from matplotlib import _api, _docstring +from matplotlib.offsetbox import AnchoredOffsetbox +from matplotlib.patches import Patch, Rectangle +from matplotlib.path import Path +from matplotlib.transforms import Bbox +from matplotlib.transforms import IdentityTransform, TransformedBbox + +from . import axes_size as Size +from .parasite_axes import HostAxes + + +class AnchoredLocatorBase(AnchoredOffsetbox): + def __init__(self, bbox_to_anchor, offsetbox, loc, + borderpad=0.5, bbox_transform=None): + super().__init__( + loc, pad=0., child=None, borderpad=borderpad, + bbox_to_anchor=bbox_to_anchor, bbox_transform=bbox_transform + ) + + def draw(self, renderer): + raise RuntimeError("No draw method should be called") + + def __call__(self, ax, renderer): + fig = ax.get_figure(root=False) + if renderer is None: + renderer = fig._get_renderer() + self.axes = ax + bbox = self.get_window_extent(renderer) + px, py = self.get_offset(bbox.width, bbox.height, 0, 0, renderer) + bbox_canvas = Bbox.from_bounds(px, py, bbox.width, bbox.height) + tr = fig.transSubfigure.inverted() + return TransformedBbox(bbox_canvas, tr) + + +class AnchoredSizeLocator(AnchoredLocatorBase): + def __init__(self, bbox_to_anchor, x_size, y_size, loc, + borderpad=0.5, bbox_transform=None): + super().__init__( + bbox_to_anchor, None, loc, + borderpad=borderpad, bbox_transform=bbox_transform + ) + + self.x_size = Size.from_any(x_size) + self.y_size = Size.from_any(y_size) + + def get_bbox(self, renderer): + bbox = self.get_bbox_to_anchor() + dpi = renderer.points_to_pixels(72.) + + r, a = self.x_size.get_size(renderer) + width = bbox.width * r + a * dpi + r, a = self.y_size.get_size(renderer) + height = bbox.height * r + a * dpi + + fontsize = renderer.points_to_pixels(self.prop.get_size_in_points()) + pad = self.pad * fontsize + + return Bbox.from_bounds(0, 0, width, height).padded(pad) + + +class AnchoredZoomLocator(AnchoredLocatorBase): + def __init__(self, parent_axes, zoom, loc, + borderpad=0.5, + bbox_to_anchor=None, + bbox_transform=None): + self.parent_axes = parent_axes + self.zoom = zoom + if bbox_to_anchor is None: + bbox_to_anchor = parent_axes.bbox + super().__init__( + bbox_to_anchor, None, loc, borderpad=borderpad, + bbox_transform=bbox_transform) + + def get_bbox(self, renderer): + bb = self.parent_axes.transData.transform_bbox(self.axes.viewLim) + fontsize = renderer.points_to_pixels(self.prop.get_size_in_points()) + pad = self.pad * fontsize + return ( + Bbox.from_bounds( + 0, 0, abs(bb.width * self.zoom), abs(bb.height * self.zoom)) + .padded(pad)) + + +class BboxPatch(Patch): + @_docstring.interpd + def __init__(self, bbox, **kwargs): + """ + Patch showing the shape bounded by a Bbox. + + Parameters + ---------- + bbox : `~matplotlib.transforms.Bbox` + Bbox to use for the extents of this patch. + + **kwargs + Patch properties. Valid arguments include: + + %(Patch:kwdoc)s + """ + if "transform" in kwargs: + raise ValueError("transform should not be set") + + kwargs["transform"] = IdentityTransform() + super().__init__(**kwargs) + self.bbox = bbox + + def get_path(self): + # docstring inherited + x0, y0, x1, y1 = self.bbox.extents + return Path._create_closed([(x0, y0), (x1, y0), (x1, y1), (x0, y1)]) + + +class BboxConnector(Patch): + @staticmethod + def get_bbox_edge_pos(bbox, loc): + """ + Return the ``(x, y)`` coordinates of corner *loc* of *bbox*; parameters + behave as documented for the `.BboxConnector` constructor. + """ + x0, y0, x1, y1 = bbox.extents + if loc == 1: + return x1, y1 + elif loc == 2: + return x0, y1 + elif loc == 3: + return x0, y0 + elif loc == 4: + return x1, y0 + + @staticmethod + def connect_bbox(bbox1, bbox2, loc1, loc2=None): + """ + Construct a `.Path` connecting corner *loc1* of *bbox1* to corner + *loc2* of *bbox2*, where parameters behave as documented as for the + `.BboxConnector` constructor. + """ + if isinstance(bbox1, Rectangle): + bbox1 = TransformedBbox(Bbox.unit(), bbox1.get_transform()) + if isinstance(bbox2, Rectangle): + bbox2 = TransformedBbox(Bbox.unit(), bbox2.get_transform()) + if loc2 is None: + loc2 = loc1 + x1, y1 = BboxConnector.get_bbox_edge_pos(bbox1, loc1) + x2, y2 = BboxConnector.get_bbox_edge_pos(bbox2, loc2) + return Path([[x1, y1], [x2, y2]]) + + @_docstring.interpd + def __init__(self, bbox1, bbox2, loc1, loc2=None, **kwargs): + """ + Connect two bboxes with a straight line. + + Parameters + ---------- + bbox1, bbox2 : `~matplotlib.transforms.Bbox` + Bounding boxes to connect. + + loc1, loc2 : {1, 2, 3, 4} + Corner of *bbox1* and *bbox2* to draw the line. Valid values are:: + + 'upper right' : 1, + 'upper left' : 2, + 'lower left' : 3, + 'lower right' : 4 + + *loc2* is optional and defaults to *loc1*. + + **kwargs + Patch properties for the line drawn. Valid arguments include: + + %(Patch:kwdoc)s + """ + if "transform" in kwargs: + raise ValueError("transform should not be set") + + kwargs["transform"] = IdentityTransform() + kwargs.setdefault( + "fill", bool({'fc', 'facecolor', 'color'}.intersection(kwargs))) + super().__init__(**kwargs) + self.bbox1 = bbox1 + self.bbox2 = bbox2 + self.loc1 = loc1 + self.loc2 = loc2 + + def get_path(self): + # docstring inherited + return self.connect_bbox(self.bbox1, self.bbox2, + self.loc1, self.loc2) + + +class BboxConnectorPatch(BboxConnector): + @_docstring.interpd + def __init__(self, bbox1, bbox2, loc1a, loc2a, loc1b, loc2b, **kwargs): + """ + Connect two bboxes with a quadrilateral. + + The quadrilateral is specified by two lines that start and end at + corners of the bboxes. The four sides of the quadrilateral are defined + by the two lines given, the line between the two corners specified in + *bbox1* and the line between the two corners specified in *bbox2*. + + Parameters + ---------- + bbox1, bbox2 : `~matplotlib.transforms.Bbox` + Bounding boxes to connect. + + loc1a, loc2a, loc1b, loc2b : {1, 2, 3, 4} + The first line connects corners *loc1a* of *bbox1* and *loc2a* of + *bbox2*; the second line connects corners *loc1b* of *bbox1* and + *loc2b* of *bbox2*. Valid values are:: + + 'upper right' : 1, + 'upper left' : 2, + 'lower left' : 3, + 'lower right' : 4 + + **kwargs + Patch properties for the line drawn: + + %(Patch:kwdoc)s + """ + if "transform" in kwargs: + raise ValueError("transform should not be set") + super().__init__(bbox1, bbox2, loc1a, loc2a, **kwargs) + self.loc1b = loc1b + self.loc2b = loc2b + + def get_path(self): + # docstring inherited + path1 = self.connect_bbox(self.bbox1, self.bbox2, self.loc1, self.loc2) + path2 = self.connect_bbox(self.bbox2, self.bbox1, + self.loc2b, self.loc1b) + path_merged = [*path1.vertices, *path2.vertices, path1.vertices[0]] + return Path(path_merged) + + +def _add_inset_axes(parent_axes, axes_class, axes_kwargs, axes_locator): + """Helper function to add an inset axes and disable navigation in it.""" + if axes_class is None: + axes_class = HostAxes + if axes_kwargs is None: + axes_kwargs = {} + fig = parent_axes.get_figure(root=False) + inset_axes = axes_class( + fig, parent_axes.get_position(), + **{"navigate": False, **axes_kwargs, "axes_locator": axes_locator}) + return fig.add_axes(inset_axes) + + +@_docstring.interpd +def inset_axes(parent_axes, width, height, loc='upper right', + bbox_to_anchor=None, bbox_transform=None, + axes_class=None, axes_kwargs=None, + borderpad=0.5): + """ + Create an inset axes with a given width and height. + + Both sizes used can be specified either in inches or percentage. + For example,:: + + inset_axes(parent_axes, width='40%%', height='30%%', loc='lower left') + + creates in inset axes in the lower left corner of *parent_axes* which spans + over 30%% in height and 40%% in width of the *parent_axes*. Since the usage + of `.inset_axes` may become slightly tricky when exceeding such standard + cases, it is recommended to read :doc:`the examples + </gallery/axes_grid1/inset_locator_demo>`. + + Notes + ----- + The meaning of *bbox_to_anchor* and *bbox_to_transform* is interpreted + differently from that of legend. The value of bbox_to_anchor + (or the return value of its get_points method; the default is + *parent_axes.bbox*) is transformed by the bbox_transform (the default + is Identity transform) and then interpreted as points in the pixel + coordinate (which is dpi dependent). + + Thus, following three calls are identical and creates an inset axes + with respect to the *parent_axes*:: + + axins = inset_axes(parent_axes, "30%%", "40%%") + axins = inset_axes(parent_axes, "30%%", "40%%", + bbox_to_anchor=parent_axes.bbox) + axins = inset_axes(parent_axes, "30%%", "40%%", + bbox_to_anchor=(0, 0, 1, 1), + bbox_transform=parent_axes.transAxes) + + Parameters + ---------- + parent_axes : `matplotlib.axes.Axes` + Axes to place the inset axes. + + width, height : float or str + Size of the inset axes to create. If a float is provided, it is + the size in inches, e.g. *width=1.3*. If a string is provided, it is + the size in relative units, e.g. *width='40%%'*. By default, i.e. if + neither *bbox_to_anchor* nor *bbox_transform* are specified, those + are relative to the parent_axes. Otherwise, they are to be understood + relative to the bounding box provided via *bbox_to_anchor*. + + loc : str, default: 'upper right' + Location to place the inset axes. Valid locations are + 'upper left', 'upper center', 'upper right', + 'center left', 'center', 'center right', + 'lower left', 'lower center', 'lower right'. + For backward compatibility, numeric values are accepted as well. + See the parameter *loc* of `.Legend` for details. + + bbox_to_anchor : tuple or `~matplotlib.transforms.BboxBase`, optional + Bbox that the inset axes will be anchored to. If None, + a tuple of (0, 0, 1, 1) is used if *bbox_transform* is set + to *parent_axes.transAxes* or *parent_axes.figure.transFigure*. + Otherwise, *parent_axes.bbox* is used. If a tuple, can be either + [left, bottom, width, height], or [left, bottom]. + If the kwargs *width* and/or *height* are specified in relative units, + the 2-tuple [left, bottom] cannot be used. Note that, + unless *bbox_transform* is set, the units of the bounding box + are interpreted in the pixel coordinate. When using *bbox_to_anchor* + with tuple, it almost always makes sense to also specify + a *bbox_transform*. This might often be the axes transform + *parent_axes.transAxes*. + + bbox_transform : `~matplotlib.transforms.Transform`, optional + Transformation for the bbox that contains the inset axes. + If None, a `.transforms.IdentityTransform` is used. The value + of *bbox_to_anchor* (or the return value of its get_points method) + is transformed by the *bbox_transform* and then interpreted + as points in the pixel coordinate (which is dpi dependent). + You may provide *bbox_to_anchor* in some normalized coordinate, + and give an appropriate transform (e.g., *parent_axes.transAxes*). + + axes_class : `~matplotlib.axes.Axes` type, default: `.HostAxes` + The type of the newly created inset axes. + + axes_kwargs : dict, optional + Keyword arguments to pass to the constructor of the inset axes. + Valid arguments include: + + %(Axes:kwdoc)s + + borderpad : float, default: 0.5 + Padding between inset axes and the bbox_to_anchor. + The units are axes font size, i.e. for a default font size of 10 points + *borderpad = 0.5* is equivalent to a padding of 5 points. + + Returns + ------- + inset_axes : *axes_class* + Inset axes object created. + """ + + if (bbox_transform in [parent_axes.transAxes, + parent_axes.get_figure(root=False).transFigure] + and bbox_to_anchor is None): + _api.warn_external("Using the axes or figure transform requires a " + "bounding box in the respective coordinates. " + "Using bbox_to_anchor=(0, 0, 1, 1) now.") + bbox_to_anchor = (0, 0, 1, 1) + if bbox_to_anchor is None: + bbox_to_anchor = parent_axes.bbox + if (isinstance(bbox_to_anchor, tuple) and + (isinstance(width, str) or isinstance(height, str))): + if len(bbox_to_anchor) != 4: + raise ValueError("Using relative units for width or height " + "requires to provide a 4-tuple or a " + "`Bbox` instance to `bbox_to_anchor.") + return _add_inset_axes( + parent_axes, axes_class, axes_kwargs, + AnchoredSizeLocator( + bbox_to_anchor, width, height, loc=loc, + bbox_transform=bbox_transform, borderpad=borderpad)) + + +@_docstring.interpd +def zoomed_inset_axes(parent_axes, zoom, loc='upper right', + bbox_to_anchor=None, bbox_transform=None, + axes_class=None, axes_kwargs=None, + borderpad=0.5): + """ + Create an anchored inset axes by scaling a parent axes. For usage, also see + :doc:`the examples </gallery/axes_grid1/inset_locator_demo2>`. + + Parameters + ---------- + parent_axes : `~matplotlib.axes.Axes` + Axes to place the inset axes. + + zoom : float + Scaling factor of the data axes. *zoom* > 1 will enlarge the + coordinates (i.e., "zoomed in"), while *zoom* < 1 will shrink the + coordinates (i.e., "zoomed out"). + + loc : str, default: 'upper right' + Location to place the inset axes. Valid locations are + 'upper left', 'upper center', 'upper right', + 'center left', 'center', 'center right', + 'lower left', 'lower center', 'lower right'. + For backward compatibility, numeric values are accepted as well. + See the parameter *loc* of `.Legend` for details. + + bbox_to_anchor : tuple or `~matplotlib.transforms.BboxBase`, optional + Bbox that the inset axes will be anchored to. If None, + *parent_axes.bbox* is used. If a tuple, can be either + [left, bottom, width, height], or [left, bottom]. + If the kwargs *width* and/or *height* are specified in relative units, + the 2-tuple [left, bottom] cannot be used. Note that + the units of the bounding box are determined through the transform + in use. When using *bbox_to_anchor* it almost always makes sense to + also specify a *bbox_transform*. This might often be the axes transform + *parent_axes.transAxes*. + + bbox_transform : `~matplotlib.transforms.Transform`, optional + Transformation for the bbox that contains the inset axes. + If None, a `.transforms.IdentityTransform` is used (i.e. pixel + coordinates). This is useful when not providing any argument to + *bbox_to_anchor*. When using *bbox_to_anchor* it almost always makes + sense to also specify a *bbox_transform*. This might often be the + axes transform *parent_axes.transAxes*. Inversely, when specifying + the axes- or figure-transform here, be aware that not specifying + *bbox_to_anchor* will use *parent_axes.bbox*, the units of which are + in display (pixel) coordinates. + + axes_class : `~matplotlib.axes.Axes` type, default: `.HostAxes` + The type of the newly created inset axes. + + axes_kwargs : dict, optional + Keyword arguments to pass to the constructor of the inset axes. + Valid arguments include: + + %(Axes:kwdoc)s + + borderpad : float, default: 0.5 + Padding between inset axes and the bbox_to_anchor. + The units are axes font size, i.e. for a default font size of 10 points + *borderpad = 0.5* is equivalent to a padding of 5 points. + + Returns + ------- + inset_axes : *axes_class* + Inset axes object created. + """ + + return _add_inset_axes( + parent_axes, axes_class, axes_kwargs, + AnchoredZoomLocator( + parent_axes, zoom=zoom, loc=loc, + bbox_to_anchor=bbox_to_anchor, bbox_transform=bbox_transform, + borderpad=borderpad)) + + +class _TransformedBboxWithCallback(TransformedBbox): + """ + Variant of `.TransformBbox` which calls *callback* before returning points. + + Used by `.mark_inset` to unstale the parent axes' viewlim as needed. + """ + + def __init__(self, *args, callback, **kwargs): + super().__init__(*args, **kwargs) + self._callback = callback + + def get_points(self): + self._callback() + return super().get_points() + + +@_docstring.interpd +def mark_inset(parent_axes, inset_axes, loc1, loc2, **kwargs): + """ + Draw a box to mark the location of an area represented by an inset axes. + + This function draws a box in *parent_axes* at the bounding box of + *inset_axes*, and shows a connection with the inset axes by drawing lines + at the corners, giving a "zoomed in" effect. + + Parameters + ---------- + parent_axes : `~matplotlib.axes.Axes` + Axes which contains the area of the inset axes. + + inset_axes : `~matplotlib.axes.Axes` + The inset axes. + + loc1, loc2 : {1, 2, 3, 4} + Corners to use for connecting the inset axes and the area in the + parent axes. + + **kwargs + Patch properties for the lines and box drawn: + + %(Patch:kwdoc)s + + Returns + ------- + pp : `~matplotlib.patches.Patch` + The patch drawn to represent the area of the inset axes. + + p1, p2 : `~matplotlib.patches.Patch` + The patches connecting two corners of the inset axes and its area. + """ + rect = _TransformedBboxWithCallback( + inset_axes.viewLim, parent_axes.transData, + callback=parent_axes._unstale_viewLim) + + kwargs.setdefault("fill", bool({'fc', 'facecolor', 'color'}.intersection(kwargs))) + pp = BboxPatch(rect, **kwargs) + parent_axes.add_patch(pp) + + p1 = BboxConnector(inset_axes.bbox, rect, loc1=loc1, **kwargs) + inset_axes.add_patch(p1) + p1.set_clip_on(False) + p2 = BboxConnector(inset_axes.bbox, rect, loc1=loc2, **kwargs) + inset_axes.add_patch(p2) + p2.set_clip_on(False) + + return pp, p1, p2 diff --git a/lib/python3.12/site-packages/mpl_toolkits/axes_grid1/mpl_axes.py b/lib/python3.12/site-packages/mpl_toolkits/axes_grid1/mpl_axes.py new file mode 100644 index 0000000000000000000000000000000000000000..51c8748758cb6da3052f0e8b05ceba427d77a3f6 --- /dev/null +++ b/lib/python3.12/site-packages/mpl_toolkits/axes_grid1/mpl_axes.py @@ -0,0 +1,128 @@ +import matplotlib.axes as maxes +from matplotlib.artist import Artist +from matplotlib.axis import XAxis, YAxis + + +class SimpleChainedObjects: + def __init__(self, objects): + self._objects = objects + + def __getattr__(self, k): + _a = SimpleChainedObjects([getattr(a, k) for a in self._objects]) + return _a + + def __call__(self, *args, **kwargs): + for m in self._objects: + m(*args, **kwargs) + + +class Axes(maxes.Axes): + + class AxisDict(dict): + def __init__(self, axes): + self.axes = axes + super().__init__() + + def __getitem__(self, k): + if isinstance(k, tuple): + r = SimpleChainedObjects( + # super() within a list comprehension needs explicit args. + [super(Axes.AxisDict, self).__getitem__(k1) for k1 in k]) + return r + elif isinstance(k, slice): + if k.start is None and k.stop is None and k.step is None: + return SimpleChainedObjects(list(self.values())) + else: + raise ValueError("Unsupported slice") + else: + return dict.__getitem__(self, k) + + def __call__(self, *v, **kwargs): + return maxes.Axes.axis(self.axes, *v, **kwargs) + + @property + def axis(self): + return self._axislines + + def clear(self): + # docstring inherited + super().clear() + # Init axis artists. + self._axislines = self.AxisDict(self) + self._axislines.update( + bottom=SimpleAxisArtist(self.xaxis, 1, self.spines["bottom"]), + top=SimpleAxisArtist(self.xaxis, 2, self.spines["top"]), + left=SimpleAxisArtist(self.yaxis, 1, self.spines["left"]), + right=SimpleAxisArtist(self.yaxis, 2, self.spines["right"])) + + +class SimpleAxisArtist(Artist): + def __init__(self, axis, axisnum, spine): + self._axis = axis + self._axisnum = axisnum + self.line = spine + + if isinstance(axis, XAxis): + self._axis_direction = ["bottom", "top"][axisnum-1] + elif isinstance(axis, YAxis): + self._axis_direction = ["left", "right"][axisnum-1] + else: + raise ValueError( + f"axis must be instance of XAxis or YAxis, but got {axis}") + super().__init__() + + @property + def major_ticks(self): + tickline = "tick%dline" % self._axisnum + return SimpleChainedObjects([getattr(tick, tickline) + for tick in self._axis.get_major_ticks()]) + + @property + def major_ticklabels(self): + label = "label%d" % self._axisnum + return SimpleChainedObjects([getattr(tick, label) + for tick in self._axis.get_major_ticks()]) + + @property + def label(self): + return self._axis.label + + def set_visible(self, b): + self.toggle(all=b) + self.line.set_visible(b) + self._axis.set_visible(True) + super().set_visible(b) + + def set_label(self, txt): + self._axis.set_label_text(txt) + + def toggle(self, all=None, ticks=None, ticklabels=None, label=None): + + if all: + _ticks, _ticklabels, _label = True, True, True + elif all is not None: + _ticks, _ticklabels, _label = False, False, False + else: + _ticks, _ticklabels, _label = None, None, None + + if ticks is not None: + _ticks = ticks + if ticklabels is not None: + _ticklabels = ticklabels + if label is not None: + _label = label + + if _ticks is not None: + tickparam = {f"tick{self._axisnum}On": _ticks} + self._axis.set_tick_params(**tickparam) + if _ticklabels is not None: + tickparam = {f"label{self._axisnum}On": _ticklabels} + self._axis.set_tick_params(**tickparam) + + if _label is not None: + pos = self._axis.get_label_position() + if (pos == self._axis_direction) and not _label: + self._axis.label.set_visible(False) + elif _label: + self._axis.label.set_visible(True) + self._axis.set_label_position(self._axis_direction) diff --git a/lib/python3.12/site-packages/mpl_toolkits/axes_grid1/parasite_axes.py b/lib/python3.12/site-packages/mpl_toolkits/axes_grid1/parasite_axes.py new file mode 100644 index 0000000000000000000000000000000000000000..f7bc2df6d7e01141254114065316a70df5f35e33 --- /dev/null +++ b/lib/python3.12/site-packages/mpl_toolkits/axes_grid1/parasite_axes.py @@ -0,0 +1,257 @@ +from matplotlib import _api, cbook +import matplotlib.artist as martist +import matplotlib.transforms as mtransforms +from matplotlib.transforms import Bbox +from .mpl_axes import Axes + + +class ParasiteAxesBase: + + def __init__(self, parent_axes, aux_transform=None, + *, viewlim_mode=None, **kwargs): + self._parent_axes = parent_axes + self.transAux = aux_transform + self.set_viewlim_mode(viewlim_mode) + kwargs["frameon"] = False + super().__init__(parent_axes.get_figure(root=False), + parent_axes._position, **kwargs) + + def clear(self): + super().clear() + martist.setp(self.get_children(), visible=False) + self._get_lines = self._parent_axes._get_lines + self._parent_axes.callbacks._connect_picklable( + "xlim_changed", self._sync_lims) + self._parent_axes.callbacks._connect_picklable( + "ylim_changed", self._sync_lims) + + def pick(self, mouseevent): + # This most likely goes to Artist.pick (depending on axes_class given + # to the factory), which only handles pick events registered on the + # axes associated with each child: + super().pick(mouseevent) + # But parasite axes are additionally given pick events from their host + # axes (cf. HostAxesBase.pick), which we handle here: + for a in self.get_children(): + if (hasattr(mouseevent.inaxes, "parasites") + and self in mouseevent.inaxes.parasites): + a.pick(mouseevent) + + # aux_transform support + + def _set_lim_and_transforms(self): + if self.transAux is not None: + self.transAxes = self._parent_axes.transAxes + self.transData = self.transAux + self._parent_axes.transData + self._xaxis_transform = mtransforms.blended_transform_factory( + self.transData, self.transAxes) + self._yaxis_transform = mtransforms.blended_transform_factory( + self.transAxes, self.transData) + else: + super()._set_lim_and_transforms() + + def set_viewlim_mode(self, mode): + _api.check_in_list([None, "equal", "transform"], mode=mode) + self._viewlim_mode = mode + + def get_viewlim_mode(self): + return self._viewlim_mode + + def _sync_lims(self, parent): + viewlim = parent.viewLim.frozen() + mode = self.get_viewlim_mode() + if mode is None: + pass + elif mode == "equal": + self.viewLim.set(viewlim) + elif mode == "transform": + self.viewLim.set(viewlim.transformed(self.transAux.inverted())) + else: + _api.check_in_list([None, "equal", "transform"], mode=mode) + + # end of aux_transform support + + +parasite_axes_class_factory = cbook._make_class_factory( + ParasiteAxesBase, "{}Parasite") +ParasiteAxes = parasite_axes_class_factory(Axes) + + +class HostAxesBase: + def __init__(self, *args, **kwargs): + self.parasites = [] + super().__init__(*args, **kwargs) + + def get_aux_axes( + self, tr=None, viewlim_mode="equal", axes_class=None, **kwargs): + """ + Add a parasite axes to this host. + + Despite this method's name, this should actually be thought of as an + ``add_parasite_axes`` method. + + .. versionchanged:: 3.7 + Defaults to same base axes class as host axes. + + Parameters + ---------- + tr : `~matplotlib.transforms.Transform` or None, default: None + If a `.Transform`, the following relation will hold: + ``parasite.transData = tr + host.transData``. + If None, the parasite's and the host's ``transData`` are unrelated. + viewlim_mode : {"equal", "transform", None}, default: "equal" + How the parasite's view limits are set: directly equal to the + parent axes ("equal"), equal after application of *tr* + ("transform"), or independently (None). + axes_class : subclass type of `~matplotlib.axes.Axes`, optional + The `~.axes.Axes` subclass that is instantiated. If None, the base + class of the host axes is used. + **kwargs + Other parameters are forwarded to the parasite axes constructor. + """ + if axes_class is None: + axes_class = self._base_axes_class + parasite_axes_class = parasite_axes_class_factory(axes_class) + ax2 = parasite_axes_class( + self, tr, viewlim_mode=viewlim_mode, **kwargs) + # note that ax2.transData == tr + ax1.transData + # Anything you draw in ax2 will match the ticks and grids of ax1. + self.parasites.append(ax2) + ax2._remove_method = self.parasites.remove + return ax2 + + def draw(self, renderer): + orig_children_len = len(self._children) + + locator = self.get_axes_locator() + if locator: + pos = locator(self, renderer) + self.set_position(pos, which="active") + self.apply_aspect(pos) + else: + self.apply_aspect() + + rect = self.get_position() + for ax in self.parasites: + ax.apply_aspect(rect) + self._children.extend(ax.get_children()) + + super().draw(renderer) + del self._children[orig_children_len:] + + def clear(self): + super().clear() + for ax in self.parasites: + ax.clear() + + def pick(self, mouseevent): + super().pick(mouseevent) + # Also pass pick events on to parasite axes and, in turn, their + # children (cf. ParasiteAxesBase.pick) + for a in self.parasites: + a.pick(mouseevent) + + def twinx(self, axes_class=None): + """ + Create a twin of Axes with a shared x-axis but independent y-axis. + + The y-axis of self will have ticks on the left and the returned axes + will have ticks on the right. + """ + ax = self._add_twin_axes(axes_class, sharex=self) + self.axis["right"].set_visible(False) + ax.axis["right"].set_visible(True) + ax.axis["left", "top", "bottom"].set_visible(False) + return ax + + def twiny(self, axes_class=None): + """ + Create a twin of Axes with a shared y-axis but independent x-axis. + + The x-axis of self will have ticks on the bottom and the returned axes + will have ticks on the top. + """ + ax = self._add_twin_axes(axes_class, sharey=self) + self.axis["top"].set_visible(False) + ax.axis["top"].set_visible(True) + ax.axis["left", "right", "bottom"].set_visible(False) + return ax + + def twin(self, aux_trans=None, axes_class=None): + """ + Create a twin of Axes with no shared axis. + + While self will have ticks on the left and bottom axis, the returned + axes will have ticks on the top and right axis. + """ + if aux_trans is None: + aux_trans = mtransforms.IdentityTransform() + ax = self._add_twin_axes( + axes_class, aux_transform=aux_trans, viewlim_mode="transform") + self.axis["top", "right"].set_visible(False) + ax.axis["top", "right"].set_visible(True) + ax.axis["left", "bottom"].set_visible(False) + return ax + + def _add_twin_axes(self, axes_class, **kwargs): + """ + Helper for `.twinx`/`.twiny`/`.twin`. + + *kwargs* are forwarded to the parasite axes constructor. + """ + if axes_class is None: + axes_class = self._base_axes_class + ax = parasite_axes_class_factory(axes_class)(self, **kwargs) + self.parasites.append(ax) + ax._remove_method = self._remove_any_twin + return ax + + def _remove_any_twin(self, ax): + self.parasites.remove(ax) + restore = ["top", "right"] + if ax._sharex: + restore.remove("top") + if ax._sharey: + restore.remove("right") + self.axis[tuple(restore)].set_visible(True) + self.axis[tuple(restore)].toggle(ticklabels=False, label=False) + + def get_tightbbox(self, renderer=None, *, call_axes_locator=True, + bbox_extra_artists=None): + bbs = [ + *[ax.get_tightbbox(renderer, call_axes_locator=call_axes_locator) + for ax in self.parasites], + super().get_tightbbox(renderer, + call_axes_locator=call_axes_locator, + bbox_extra_artists=bbox_extra_artists)] + return Bbox.union([b for b in bbs if b.width != 0 or b.height != 0]) + + +host_axes_class_factory = host_subplot_class_factory = \ + cbook._make_class_factory(HostAxesBase, "{}HostAxes", "_base_axes_class") +HostAxes = SubplotHost = host_axes_class_factory(Axes) + + +def host_axes(*args, axes_class=Axes, figure=None, **kwargs): + """ + Create axes that can act as a hosts to parasitic axes. + + Parameters + ---------- + figure : `~matplotlib.figure.Figure` + Figure to which the axes will be added. Defaults to the current figure + `.pyplot.gcf()`. + + *args, **kwargs + Will be passed on to the underlying `~.axes.Axes` object creation. + """ + import matplotlib.pyplot as plt + host_axes_class = host_axes_class_factory(axes_class) + if figure is None: + figure = plt.gcf() + ax = host_axes_class(figure, *args, **kwargs) + figure.add_axes(ax) + return ax + + +host_subplot = host_axes diff --git a/lib/python3.12/site-packages/mpl_toolkits/axes_grid1/tests/__init__.py b/lib/python3.12/site-packages/mpl_toolkits/axes_grid1/tests/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..ea4d8ed16a6a24a8c15ab2956ef678a7f256cd80 --- /dev/null +++ b/lib/python3.12/site-packages/mpl_toolkits/axes_grid1/tests/__init__.py @@ -0,0 +1,10 @@ +from pathlib import Path + + +# Check that the test directories exist +if not (Path(__file__).parent / "baseline_images").exists(): + raise OSError( + 'The baseline image directory does not exist. ' + 'This is most likely because the test data is not installed. ' + 'You may need to install matplotlib from source to get the ' + 'test data.') diff --git a/lib/python3.12/site-packages/mpl_toolkits/axes_grid1/tests/__pycache__/__init__.cpython-312.pyc b/lib/python3.12/site-packages/mpl_toolkits/axes_grid1/tests/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c082c0bddb96078b72be36fc13f58200d8281fe5 Binary files /dev/null and b/lib/python3.12/site-packages/mpl_toolkits/axes_grid1/tests/__pycache__/__init__.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/mpl_toolkits/axes_grid1/tests/__pycache__/conftest.cpython-312.pyc b/lib/python3.12/site-packages/mpl_toolkits/axes_grid1/tests/__pycache__/conftest.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..93f3df9a1a67f634ca15c4d1d13ac09b32021db8 Binary files /dev/null and b/lib/python3.12/site-packages/mpl_toolkits/axes_grid1/tests/__pycache__/conftest.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/mpl_toolkits/axes_grid1/tests/__pycache__/test_axes_grid1.cpython-312.pyc b/lib/python3.12/site-packages/mpl_toolkits/axes_grid1/tests/__pycache__/test_axes_grid1.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d820860a49ad5de3e786a5a975fa30bca56422aa Binary files /dev/null and b/lib/python3.12/site-packages/mpl_toolkits/axes_grid1/tests/__pycache__/test_axes_grid1.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/mpl_toolkits/axes_grid1/tests/conftest.py b/lib/python3.12/site-packages/mpl_toolkits/axes_grid1/tests/conftest.py new file mode 100644 index 0000000000000000000000000000000000000000..61c2de3e07bac4db323f8704961264d123e01544 --- /dev/null +++ b/lib/python3.12/site-packages/mpl_toolkits/axes_grid1/tests/conftest.py @@ -0,0 +1,2 @@ +from matplotlib.testing.conftest import (mpl_test_settings, # noqa + pytest_configure, pytest_unconfigure) diff --git a/lib/python3.12/site-packages/mpl_toolkits/axes_grid1/tests/test_axes_grid1.py b/lib/python3.12/site-packages/mpl_toolkits/axes_grid1/tests/test_axes_grid1.py new file mode 100644 index 0000000000000000000000000000000000000000..b045e8dc87569b0ea5653cf8b0c6307135b69c75 --- /dev/null +++ b/lib/python3.12/site-packages/mpl_toolkits/axes_grid1/tests/test_axes_grid1.py @@ -0,0 +1,782 @@ +from itertools import product +import io +import platform + +import matplotlib as mpl +import matplotlib.pyplot as plt +import matplotlib.ticker as mticker +from matplotlib import cbook +from matplotlib.backend_bases import MouseEvent +from matplotlib.colors import LogNorm +from matplotlib.patches import Circle, Ellipse +from matplotlib.transforms import Bbox, TransformedBbox +from matplotlib.testing.decorators import ( + check_figures_equal, image_comparison, remove_ticks_and_titles) + +from mpl_toolkits.axes_grid1 import ( + axes_size as Size, + host_subplot, make_axes_locatable, + Grid, AxesGrid, ImageGrid) +from mpl_toolkits.axes_grid1.anchored_artists import ( + AnchoredAuxTransformBox, AnchoredDrawingArea, + AnchoredDirectionArrows, AnchoredSizeBar) +from mpl_toolkits.axes_grid1.axes_divider import ( + Divider, HBoxDivider, make_axes_area_auto_adjustable, SubplotDivider, + VBoxDivider) +from mpl_toolkits.axes_grid1.axes_rgb import RGBAxes +from mpl_toolkits.axes_grid1.inset_locator import ( + zoomed_inset_axes, mark_inset, inset_axes, BboxConnectorPatch) +import mpl_toolkits.axes_grid1.mpl_axes +import pytest + +import numpy as np +from numpy.testing import assert_array_equal, assert_array_almost_equal + + +def test_divider_append_axes(): + fig, ax = plt.subplots() + divider = make_axes_locatable(ax) + axs = { + "main": ax, + "top": divider.append_axes("top", 1.2, pad=0.1, sharex=ax), + "bottom": divider.append_axes("bottom", 1.2, pad=0.1, sharex=ax), + "left": divider.append_axes("left", 1.2, pad=0.1, sharey=ax), + "right": divider.append_axes("right", 1.2, pad=0.1, sharey=ax), + } + fig.canvas.draw() + bboxes = {k: axs[k].get_window_extent() for k in axs} + dpi = fig.dpi + assert bboxes["top"].height == pytest.approx(1.2 * dpi) + assert bboxes["bottom"].height == pytest.approx(1.2 * dpi) + assert bboxes["left"].width == pytest.approx(1.2 * dpi) + assert bboxes["right"].width == pytest.approx(1.2 * dpi) + assert bboxes["top"].y0 - bboxes["main"].y1 == pytest.approx(0.1 * dpi) + assert bboxes["main"].y0 - bboxes["bottom"].y1 == pytest.approx(0.1 * dpi) + assert bboxes["main"].x0 - bboxes["left"].x1 == pytest.approx(0.1 * dpi) + assert bboxes["right"].x0 - bboxes["main"].x1 == pytest.approx(0.1 * dpi) + assert bboxes["left"].y0 == bboxes["main"].y0 == bboxes["right"].y0 + assert bboxes["left"].y1 == bboxes["main"].y1 == bboxes["right"].y1 + assert bboxes["top"].x0 == bboxes["main"].x0 == bboxes["bottom"].x0 + assert bboxes["top"].x1 == bboxes["main"].x1 == bboxes["bottom"].x1 + + +# Update style when regenerating the test image +@image_comparison(['twin_axes_empty_and_removed'], extensions=["png"], tol=1, + style=('classic', '_classic_test_patch')) +def test_twin_axes_empty_and_removed(): + # Purely cosmetic font changes (avoid overlap) + mpl.rcParams.update( + {"font.size": 8, "xtick.labelsize": 8, "ytick.labelsize": 8}) + generators = ["twinx", "twiny", "twin"] + modifiers = ["", "host invisible", "twin removed", "twin invisible", + "twin removed\nhost invisible"] + # Unmodified host subplot at the beginning for reference + h = host_subplot(len(modifiers)+1, len(generators), 2) + h.text(0.5, 0.5, "host_subplot", + horizontalalignment="center", verticalalignment="center") + # Host subplots with various modifications (twin*, visibility) applied + for i, (mod, gen) in enumerate(product(modifiers, generators), + len(generators) + 1): + h = host_subplot(len(modifiers)+1, len(generators), i) + t = getattr(h, gen)() + if "twin invisible" in mod: + t.axis[:].set_visible(False) + if "twin removed" in mod: + t.remove() + if "host invisible" in mod: + h.axis[:].set_visible(False) + h.text(0.5, 0.5, gen + ("\n" + mod if mod else ""), + horizontalalignment="center", verticalalignment="center") + plt.subplots_adjust(wspace=0.5, hspace=1) + + +def test_twin_axes_both_with_units(): + host = host_subplot(111) + with pytest.warns(mpl.MatplotlibDeprecationWarning): + host.plot_date([0, 1, 2], [0, 1, 2], xdate=False, ydate=True) + twin = host.twinx() + twin.plot(["a", "b", "c"]) + assert host.get_yticklabels()[0].get_text() == "00:00:00" + assert twin.get_yticklabels()[0].get_text() == "a" + + +def test_axesgrid_colorbar_log_smoketest(): + fig = plt.figure() + grid = AxesGrid(fig, 111, # modified to be only subplot + nrows_ncols=(1, 1), + ngrids=1, + label_mode="L", + cbar_location="top", + cbar_mode="single", + ) + + Z = 10000 * np.random.rand(10, 10) + im = grid[0].imshow(Z, interpolation="nearest", norm=LogNorm()) + + grid.cbar_axes[0].colorbar(im) + + +def test_inset_colorbar_tight_layout_smoketest(): + fig, ax = plt.subplots(1, 1) + pts = ax.scatter([0, 1], [0, 1], c=[1, 5]) + + cax = inset_axes(ax, width="3%", height="70%") + plt.colorbar(pts, cax=cax) + + with pytest.warns(UserWarning, match="This figure includes Axes"): + # Will warn, but not raise an error + plt.tight_layout() + + +@image_comparison(['inset_locator.png'], style='default', remove_text=True) +def test_inset_locator(): + fig, ax = plt.subplots(figsize=[5, 4]) + + # prepare the demo image + # Z is a 15x15 array + Z = cbook.get_sample_data("axes_grid/bivariate_normal.npy") + extent = (-3, 4, -4, 3) + Z2 = np.zeros((150, 150)) + ny, nx = Z.shape + Z2[30:30+ny, 30:30+nx] = Z + + ax.imshow(Z2, extent=extent, interpolation="nearest", + origin="lower") + + axins = zoomed_inset_axes(ax, zoom=6, loc='upper right') + axins.imshow(Z2, extent=extent, interpolation="nearest", + origin="lower") + axins.yaxis.get_major_locator().set_params(nbins=7) + axins.xaxis.get_major_locator().set_params(nbins=7) + # sub region of the original image + x1, x2, y1, y2 = -1.5, -0.9, -2.5, -1.9 + axins.set_xlim(x1, x2) + axins.set_ylim(y1, y2) + + plt.xticks(visible=False) + plt.yticks(visible=False) + + # draw a bbox of the region of the inset axes in the parent axes and + # connecting lines between the bbox and the inset axes area + mark_inset(ax, axins, loc1=2, loc2=4, fc="none", ec="0.5") + + asb = AnchoredSizeBar(ax.transData, + 0.5, + '0.5', + loc='lower center', + pad=0.1, borderpad=0.5, sep=5, + frameon=False) + ax.add_artist(asb) + + +@image_comparison(['inset_axes.png'], style='default', remove_text=True) +def test_inset_axes(): + fig, ax = plt.subplots(figsize=[5, 4]) + + # prepare the demo image + # Z is a 15x15 array + Z = cbook.get_sample_data("axes_grid/bivariate_normal.npy") + extent = (-3, 4, -4, 3) + Z2 = np.zeros((150, 150)) + ny, nx = Z.shape + Z2[30:30+ny, 30:30+nx] = Z + + ax.imshow(Z2, extent=extent, interpolation="nearest", + origin="lower") + + # creating our inset axes with a bbox_transform parameter + axins = inset_axes(ax, width=1., height=1., bbox_to_anchor=(1, 1), + bbox_transform=ax.transAxes) + + axins.imshow(Z2, extent=extent, interpolation="nearest", + origin="lower") + axins.yaxis.get_major_locator().set_params(nbins=7) + axins.xaxis.get_major_locator().set_params(nbins=7) + # sub region of the original image + x1, x2, y1, y2 = -1.5, -0.9, -2.5, -1.9 + axins.set_xlim(x1, x2) + axins.set_ylim(y1, y2) + + plt.xticks(visible=False) + plt.yticks(visible=False) + + # draw a bbox of the region of the inset axes in the parent axes and + # connecting lines between the bbox and the inset axes area + mark_inset(ax, axins, loc1=2, loc2=4, fc="none", ec="0.5") + + asb = AnchoredSizeBar(ax.transData, + 0.5, + '0.5', + loc='lower center', + pad=0.1, borderpad=0.5, sep=5, + frameon=False) + ax.add_artist(asb) + + +def test_inset_axes_complete(): + dpi = 100 + figsize = (6, 5) + fig, ax = plt.subplots(figsize=figsize, dpi=dpi) + fig.subplots_adjust(.1, .1, .9, .9) + + ins = inset_axes(ax, width=2., height=2., borderpad=0) + fig.canvas.draw() + assert_array_almost_equal( + ins.get_position().extents, + [(0.9*figsize[0]-2.)/figsize[0], (0.9*figsize[1]-2.)/figsize[1], + 0.9, 0.9]) + + ins = inset_axes(ax, width="40%", height="30%", borderpad=0) + fig.canvas.draw() + assert_array_almost_equal( + ins.get_position().extents, [.9-.8*.4, .9-.8*.3, 0.9, 0.9]) + + ins = inset_axes(ax, width=1., height=1.2, bbox_to_anchor=(200, 100), + loc=3, borderpad=0) + fig.canvas.draw() + assert_array_almost_equal( + ins.get_position().extents, + [200/dpi/figsize[0], 100/dpi/figsize[1], + (200/dpi+1)/figsize[0], (100/dpi+1.2)/figsize[1]]) + + ins1 = inset_axes(ax, width="35%", height="60%", loc=3, borderpad=1) + ins2 = inset_axes(ax, width="100%", height="100%", + bbox_to_anchor=(0, 0, .35, .60), + bbox_transform=ax.transAxes, loc=3, borderpad=1) + fig.canvas.draw() + assert_array_equal(ins1.get_position().extents, + ins2.get_position().extents) + + with pytest.raises(ValueError): + ins = inset_axes(ax, width="40%", height="30%", + bbox_to_anchor=(0.4, 0.5)) + + with pytest.warns(UserWarning): + ins = inset_axes(ax, width="40%", height="30%", + bbox_transform=ax.transAxes) + + +def test_inset_axes_tight(): + # gh-26287 found that inset_axes raised with bbox_inches=tight + fig, ax = plt.subplots() + inset_axes(ax, width=1.3, height=0.9) + + f = io.BytesIO() + fig.savefig(f, bbox_inches="tight") + + +@image_comparison(['fill_facecolor.png'], remove_text=True, style='mpl20') +def test_fill_facecolor(): + fig, ax = plt.subplots(1, 5) + fig.set_size_inches(5, 5) + for i in range(1, 4): + ax[i].yaxis.set_visible(False) + ax[4].yaxis.tick_right() + bbox = Bbox.from_extents(0, 0.4, 1, 0.6) + + # fill with blue by setting 'fc' field + bbox1 = TransformedBbox(bbox, ax[0].transData) + bbox2 = TransformedBbox(bbox, ax[1].transData) + # set color to BboxConnectorPatch + p = BboxConnectorPatch( + bbox1, bbox2, loc1a=1, loc2a=2, loc1b=4, loc2b=3, + ec="r", fc="b") + p.set_clip_on(False) + ax[0].add_patch(p) + # set color to marked area + axins = zoomed_inset_axes(ax[0], 1, loc='upper right') + axins.set_xlim(0, 0.2) + axins.set_ylim(0, 0.2) + plt.gca().axes.xaxis.set_ticks([]) + plt.gca().axes.yaxis.set_ticks([]) + mark_inset(ax[0], axins, loc1=2, loc2=4, fc="b", ec="0.5") + + # fill with yellow by setting 'facecolor' field + bbox3 = TransformedBbox(bbox, ax[1].transData) + bbox4 = TransformedBbox(bbox, ax[2].transData) + # set color to BboxConnectorPatch + p = BboxConnectorPatch( + bbox3, bbox4, loc1a=1, loc2a=2, loc1b=4, loc2b=3, + ec="r", facecolor="y") + p.set_clip_on(False) + ax[1].add_patch(p) + # set color to marked area + axins = zoomed_inset_axes(ax[1], 1, loc='upper right') + axins.set_xlim(0, 0.2) + axins.set_ylim(0, 0.2) + plt.gca().axes.xaxis.set_ticks([]) + plt.gca().axes.yaxis.set_ticks([]) + mark_inset(ax[1], axins, loc1=2, loc2=4, facecolor="y", ec="0.5") + + # fill with green by setting 'color' field + bbox5 = TransformedBbox(bbox, ax[2].transData) + bbox6 = TransformedBbox(bbox, ax[3].transData) + # set color to BboxConnectorPatch + p = BboxConnectorPatch( + bbox5, bbox6, loc1a=1, loc2a=2, loc1b=4, loc2b=3, + ec="r", color="g") + p.set_clip_on(False) + ax[2].add_patch(p) + # set color to marked area + axins = zoomed_inset_axes(ax[2], 1, loc='upper right') + axins.set_xlim(0, 0.2) + axins.set_ylim(0, 0.2) + plt.gca().axes.xaxis.set_ticks([]) + plt.gca().axes.yaxis.set_ticks([]) + mark_inset(ax[2], axins, loc1=2, loc2=4, color="g", ec="0.5") + + # fill with green but color won't show if set fill to False + bbox7 = TransformedBbox(bbox, ax[3].transData) + bbox8 = TransformedBbox(bbox, ax[4].transData) + # BboxConnectorPatch won't show green + p = BboxConnectorPatch( + bbox7, bbox8, loc1a=1, loc2a=2, loc1b=4, loc2b=3, + ec="r", fc="g", fill=False) + p.set_clip_on(False) + ax[3].add_patch(p) + # marked area won't show green + axins = zoomed_inset_axes(ax[3], 1, loc='upper right') + axins.set_xlim(0, 0.2) + axins.set_ylim(0, 0.2) + axins.xaxis.set_ticks([]) + axins.yaxis.set_ticks([]) + mark_inset(ax[3], axins, loc1=2, loc2=4, fc="g", ec="0.5", fill=False) + + +# Update style when regenerating the test image +@image_comparison(['zoomed_axes.png', 'inverted_zoomed_axes.png'], + style=('classic', '_classic_test_patch'), + tol=0 if platform.machine() == 'x86_64' else 0.02) +def test_zooming_with_inverted_axes(): + fig, ax = plt.subplots() + ax.plot([1, 2, 3], [1, 2, 3]) + ax.axis([1, 3, 1, 3]) + inset_ax = zoomed_inset_axes(ax, zoom=2.5, loc='lower right') + inset_ax.axis([1.1, 1.4, 1.1, 1.4]) + + fig, ax = plt.subplots() + ax.plot([1, 2, 3], [1, 2, 3]) + ax.axis([3, 1, 3, 1]) + inset_ax = zoomed_inset_axes(ax, zoom=2.5, loc='lower right') + inset_ax.axis([1.4, 1.1, 1.4, 1.1]) + + +# Update style when regenerating the test image +@image_comparison(['anchored_direction_arrows.png'], + tol=0 if platform.machine() == 'x86_64' else 0.01, + style=('classic', '_classic_test_patch')) +def test_anchored_direction_arrows(): + fig, ax = plt.subplots() + ax.imshow(np.zeros((10, 10)), interpolation='nearest') + + simple_arrow = AnchoredDirectionArrows(ax.transAxes, 'X', 'Y') + ax.add_artist(simple_arrow) + + +# Update style when regenerating the test image +@image_comparison(['anchored_direction_arrows_many_args.png'], + style=('classic', '_classic_test_patch')) +def test_anchored_direction_arrows_many_args(): + fig, ax = plt.subplots() + ax.imshow(np.ones((10, 10))) + + direction_arrows = AnchoredDirectionArrows( + ax.transAxes, 'A', 'B', loc='upper right', color='red', + aspect_ratio=-0.5, pad=0.6, borderpad=2, frameon=True, alpha=0.7, + sep_x=-0.06, sep_y=-0.08, back_length=0.1, head_width=9, + head_length=10, tail_width=5) + ax.add_artist(direction_arrows) + + +def test_axes_locatable_position(): + fig, ax = plt.subplots() + divider = make_axes_locatable(ax) + with mpl.rc_context({"figure.subplot.wspace": 0.02}): + cax = divider.append_axes('right', size='5%') + fig.canvas.draw() + assert np.isclose(cax.get_position(original=False).width, + 0.03621495327102808) + + +@image_comparison(['image_grid_each_left_label_mode_all.png'], style='mpl20', + savefig_kwarg={'bbox_inches': 'tight'}) +def test_image_grid_each_left_label_mode_all(): + imdata = np.arange(100).reshape((10, 10)) + + fig = plt.figure(1, (3, 3)) + grid = ImageGrid(fig, (1, 1, 1), nrows_ncols=(3, 2), axes_pad=(0.5, 0.3), + cbar_mode="each", cbar_location="left", cbar_size="15%", + label_mode="all") + # 3-tuple rect => SubplotDivider + assert isinstance(grid.get_divider(), SubplotDivider) + assert grid.get_axes_pad() == (0.5, 0.3) + assert grid.get_aspect() # True by default for ImageGrid + for ax, cax in zip(grid, grid.cbar_axes): + im = ax.imshow(imdata, interpolation='none') + cax.colorbar(im) + + +@image_comparison(['image_grid_single_bottom_label_mode_1.png'], style='mpl20', + savefig_kwarg={'bbox_inches': 'tight'}) +def test_image_grid_single_bottom(): + imdata = np.arange(100).reshape((10, 10)) + + fig = plt.figure(1, (2.5, 1.5)) + grid = ImageGrid(fig, (0, 0, 1, 1), nrows_ncols=(1, 3), + axes_pad=(0.2, 0.15), cbar_mode="single", cbar_pad=0.3, + cbar_location="bottom", cbar_size="10%", label_mode="1") + # 4-tuple rect => Divider, isinstance will give True for SubplotDivider + assert type(grid.get_divider()) is Divider + for i in range(3): + im = grid[i].imshow(imdata, interpolation='none') + grid.cbar_axes[0].colorbar(im) + + +def test_image_grid_label_mode_invalid(): + fig = plt.figure() + with pytest.raises(ValueError, match="'foo' is not a valid value for mode"): + ImageGrid(fig, (0, 0, 1, 1), (2, 1), label_mode="foo") + + +@image_comparison(['image_grid.png'], + remove_text=True, style='mpl20', + savefig_kwarg={'bbox_inches': 'tight'}) +def test_image_grid(): + # test that image grid works with bbox_inches=tight. + im = np.arange(100).reshape((10, 10)) + + fig = plt.figure(1, (4, 4)) + grid = ImageGrid(fig, 111, nrows_ncols=(2, 2), axes_pad=0.1) + assert grid.get_axes_pad() == (0.1, 0.1) + for i in range(4): + grid[i].imshow(im, interpolation='nearest') + + +def test_gettightbbox(): + fig, ax = plt.subplots(figsize=(8, 6)) + + l, = ax.plot([1, 2, 3], [0, 1, 0]) + + ax_zoom = zoomed_inset_axes(ax, 4) + ax_zoom.plot([1, 2, 3], [0, 1, 0]) + + mark_inset(ax, ax_zoom, loc1=1, loc2=3, fc="none", ec='0.3') + + remove_ticks_and_titles(fig) + bbox = fig.get_tightbbox(fig.canvas.get_renderer()) + np.testing.assert_array_almost_equal(bbox.extents, + [-17.7, -13.9, 7.2, 5.4]) + + +@pytest.mark.parametrize("click_on", ["big", "small"]) +@pytest.mark.parametrize("big_on_axes,small_on_axes", [ + ("gca", "gca"), + ("host", "host"), + ("host", "parasite"), + ("parasite", "host"), + ("parasite", "parasite") +]) +def test_picking_callbacks_overlap(big_on_axes, small_on_axes, click_on): + """Test pick events on normal, host or parasite axes.""" + # Two rectangles are drawn and "clicked on", a small one and a big one + # enclosing the small one. The axis on which they are drawn as well as the + # rectangle that is clicked on are varied. + # In each case we expect that both rectangles are picked if we click on the + # small one and only the big one is picked if we click on the big one. + # Also tests picking on normal axes ("gca") as a control. + big = plt.Rectangle((0.25, 0.25), 0.5, 0.5, picker=5) + small = plt.Rectangle((0.4, 0.4), 0.2, 0.2, facecolor="r", picker=5) + # Machinery for "receiving" events + received_events = [] + def on_pick(event): + received_events.append(event) + plt.gcf().canvas.mpl_connect('pick_event', on_pick) + # Shortcut + rectangles_on_axes = (big_on_axes, small_on_axes) + # Axes setup + axes = {"gca": None, "host": None, "parasite": None} + if "gca" in rectangles_on_axes: + axes["gca"] = plt.gca() + if "host" in rectangles_on_axes or "parasite" in rectangles_on_axes: + axes["host"] = host_subplot(111) + axes["parasite"] = axes["host"].twin() + # Add rectangles to axes + axes[big_on_axes].add_patch(big) + axes[small_on_axes].add_patch(small) + # Simulate picking with click mouse event + if click_on == "big": + click_axes = axes[big_on_axes] + axes_coords = (0.3, 0.3) + else: + click_axes = axes[small_on_axes] + axes_coords = (0.5, 0.5) + # In reality mouse events never happen on parasite axes, only host axes + if click_axes is axes["parasite"]: + click_axes = axes["host"] + (x, y) = click_axes.transAxes.transform(axes_coords) + m = MouseEvent("button_press_event", click_axes.get_figure(root=True).canvas, x, y, + button=1) + click_axes.pick(m) + # Checks + expected_n_events = 2 if click_on == "small" else 1 + assert len(received_events) == expected_n_events + event_rects = [event.artist for event in received_events] + assert big in event_rects + if click_on == "small": + assert small in event_rects + + +@image_comparison(['anchored_artists.png'], remove_text=True, style='mpl20') +def test_anchored_artists(): + fig, ax = plt.subplots(figsize=(3, 3)) + ada = AnchoredDrawingArea(40, 20, 0, 0, loc='upper right', pad=0., + frameon=False) + p1 = Circle((10, 10), 10) + ada.drawing_area.add_artist(p1) + p2 = Circle((30, 10), 5, fc="r") + ada.drawing_area.add_artist(p2) + ax.add_artist(ada) + + box = AnchoredAuxTransformBox(ax.transData, loc='upper left') + el = Ellipse((0, 0), width=0.1, height=0.4, angle=30, color='cyan') + box.drawing_area.add_artist(el) + ax.add_artist(box) + + # This block used to test the AnchoredEllipse class, but that was removed. The block + # remains, though it duplicates the above ellipse, so that the test image doesn't + # need to be regenerated. + box = AnchoredAuxTransformBox(ax.transData, loc='lower left', frameon=True, + pad=0.5, borderpad=0.4) + el = Ellipse((0, 0), width=0.1, height=0.25, angle=-60) + box.drawing_area.add_artist(el) + ax.add_artist(box) + + asb = AnchoredSizeBar(ax.transData, 0.2, r"0.2 units", loc='lower right', + pad=0.3, borderpad=0.4, sep=4, fill_bar=True, + frameon=False, label_top=True, prop={'size': 20}, + size_vertical=0.05, color='green') + ax.add_artist(asb) + + +def test_hbox_divider(): + arr1 = np.arange(20).reshape((4, 5)) + arr2 = np.arange(20).reshape((5, 4)) + + fig, (ax1, ax2) = plt.subplots(1, 2) + ax1.imshow(arr1) + ax2.imshow(arr2) + + pad = 0.5 # inches. + divider = HBoxDivider( + fig, 111, # Position of combined axes. + horizontal=[Size.AxesX(ax1), Size.Fixed(pad), Size.AxesX(ax2)], + vertical=[Size.AxesY(ax1), Size.Scaled(1), Size.AxesY(ax2)]) + ax1.set_axes_locator(divider.new_locator(0)) + ax2.set_axes_locator(divider.new_locator(2)) + + fig.canvas.draw() + p1 = ax1.get_position() + p2 = ax2.get_position() + assert p1.height == p2.height + assert p2.width / p1.width == pytest.approx((4 / 5) ** 2) + + +def test_vbox_divider(): + arr1 = np.arange(20).reshape((4, 5)) + arr2 = np.arange(20).reshape((5, 4)) + + fig, (ax1, ax2) = plt.subplots(1, 2) + ax1.imshow(arr1) + ax2.imshow(arr2) + + pad = 0.5 # inches. + divider = VBoxDivider( + fig, 111, # Position of combined axes. + horizontal=[Size.AxesX(ax1), Size.Scaled(1), Size.AxesX(ax2)], + vertical=[Size.AxesY(ax1), Size.Fixed(pad), Size.AxesY(ax2)]) + ax1.set_axes_locator(divider.new_locator(0)) + ax2.set_axes_locator(divider.new_locator(2)) + + fig.canvas.draw() + p1 = ax1.get_position() + p2 = ax2.get_position() + assert p1.width == p2.width + assert p1.height / p2.height == pytest.approx((4 / 5) ** 2) + + +def test_axes_class_tuple(): + fig = plt.figure() + axes_class = (mpl_toolkits.axes_grid1.mpl_axes.Axes, {}) + gr = AxesGrid(fig, 111, nrows_ncols=(1, 1), axes_class=axes_class) + + +def test_grid_axes_lists(): + """Test Grid axes_all, axes_row and axes_column relationship.""" + fig = plt.figure() + grid = Grid(fig, 111, (2, 3), direction="row") + assert_array_equal(grid, grid.axes_all) + assert_array_equal(grid.axes_row, np.transpose(grid.axes_column)) + assert_array_equal(grid, np.ravel(grid.axes_row), "row") + assert grid.get_geometry() == (2, 3) + grid = Grid(fig, 111, (2, 3), direction="column") + assert_array_equal(grid, np.ravel(grid.axes_column), "column") + + +@pytest.mark.parametrize('direction', ('row', 'column')) +def test_grid_axes_position(direction): + """Test positioning of the axes in Grid.""" + fig = plt.figure() + grid = Grid(fig, 111, (2, 2), direction=direction) + loc = [ax.get_axes_locator() for ax in np.ravel(grid.axes_row)] + # Test nx. + assert loc[1].args[0] > loc[0].args[0] + assert loc[0].args[0] == loc[2].args[0] + assert loc[3].args[0] == loc[1].args[0] + # Test ny. + assert loc[2].args[1] < loc[0].args[1] + assert loc[0].args[1] == loc[1].args[1] + assert loc[3].args[1] == loc[2].args[1] + + +@pytest.mark.parametrize('rect, ngrids, error, message', ( + ((1, 1), None, TypeError, "Incorrect rect format"), + (111, -1, ValueError, "ngrids must be positive"), + (111, 7, ValueError, "ngrids must be positive"), +)) +def test_grid_errors(rect, ngrids, error, message): + fig = plt.figure() + with pytest.raises(error, match=message): + Grid(fig, rect, (2, 3), ngrids=ngrids) + + +@pytest.mark.parametrize('anchor, error, message', ( + (None, TypeError, "anchor must be str"), + ("CC", ValueError, "'CC' is not a valid value for anchor"), + ((1, 1, 1), TypeError, "anchor must be str"), +)) +def test_divider_errors(anchor, error, message): + fig = plt.figure() + with pytest.raises(error, match=message): + Divider(fig, [0, 0, 1, 1], [Size.Fixed(1)], [Size.Fixed(1)], + anchor=anchor) + + +@check_figures_equal(extensions=["png"]) +def test_mark_inset_unstales_viewlim(fig_test, fig_ref): + inset, full = fig_test.subplots(1, 2) + full.plot([0, 5], [0, 5]) + inset.set(xlim=(1, 2), ylim=(1, 2)) + # Check that mark_inset unstales full's viewLim before drawing the marks. + mark_inset(full, inset, 1, 4) + + inset, full = fig_ref.subplots(1, 2) + full.plot([0, 5], [0, 5]) + inset.set(xlim=(1, 2), ylim=(1, 2)) + mark_inset(full, inset, 1, 4) + # Manually unstale the full's viewLim. + fig_ref.canvas.draw() + + +def test_auto_adjustable(): + fig = plt.figure() + ax = fig.add_axes([0, 0, 1, 1]) + pad = 0.1 + make_axes_area_auto_adjustable(ax, pad=pad) + fig.canvas.draw() + tbb = ax.get_tightbbox() + assert tbb.x0 == pytest.approx(pad * fig.dpi) + assert tbb.x1 == pytest.approx(fig.bbox.width - pad * fig.dpi) + assert tbb.y0 == pytest.approx(pad * fig.dpi) + assert tbb.y1 == pytest.approx(fig.bbox.height - pad * fig.dpi) + + +# Update style when regenerating the test image +@image_comparison(['rgb_axes.png'], remove_text=True, + style=('classic', '_classic_test_patch')) +def test_rgb_axes(): + fig = plt.figure() + ax = RGBAxes(fig, (0.1, 0.1, 0.8, 0.8), pad=0.1) + rng = np.random.default_rng(19680801) + r = rng.random((5, 5)) + g = rng.random((5, 5)) + b = rng.random((5, 5)) + ax.imshow_rgb(r, g, b, interpolation='none') + + +# The original version of this test relied on mpl_toolkits's slightly different +# colorbar implementation; moving to matplotlib's own colorbar implementation +# caused the small image comparison error. +@image_comparison(['imagegrid_cbar_mode.png'], + remove_text=True, style='mpl20', tol=0.3) +def test_imagegrid_cbar_mode_edge(): + arr = np.arange(16).reshape((4, 4)) + + fig = plt.figure(figsize=(18, 9)) + + positions = (241, 242, 243, 244, 245, 246, 247, 248) + directions = ['row']*4 + ['column']*4 + cbar_locations = ['left', 'right', 'top', 'bottom']*2 + + for position, direction, location in zip( + positions, directions, cbar_locations): + grid = ImageGrid(fig, position, + nrows_ncols=(2, 2), + direction=direction, + cbar_location=location, + cbar_size='20%', + cbar_mode='edge') + ax1, ax2, ax3, ax4 = grid + + ax1.imshow(arr, cmap='nipy_spectral') + ax2.imshow(arr.T, cmap='hot') + ax3.imshow(np.hypot(arr, arr.T), cmap='jet') + ax4.imshow(np.arctan2(arr, arr.T), cmap='hsv') + + # In each row/column, the "first" colorbars must be overwritten by the + # "second" ones. To achieve this, clear out the axes first. + for ax in grid: + ax.cax.cla() + cb = ax.cax.colorbar(ax.images[0]) + + +def test_imagegrid(): + fig = plt.figure() + grid = ImageGrid(fig, 111, nrows_ncols=(1, 1)) + ax = grid[0] + im = ax.imshow([[1, 2]], norm=mpl.colors.LogNorm()) + cb = ax.cax.colorbar(im) + assert isinstance(cb.locator, mticker.LogLocator) + + +def test_removal(): + import matplotlib.pyplot as plt + import mpl_toolkits.axisartist as AA + fig = plt.figure() + ax = host_subplot(111, axes_class=AA.Axes, figure=fig) + col = ax.fill_between(range(5), 0, range(5)) + fig.canvas.draw() + col.remove() + fig.canvas.draw() + + +@image_comparison(['anchored_locator_base_call.png'], style="mpl20") +def test_anchored_locator_base_call(): + fig = plt.figure(figsize=(3, 3)) + fig1, fig2 = fig.subfigures(nrows=2, ncols=1) + + ax = fig1.subplots() + ax.set(aspect=1, xlim=(-15, 15), ylim=(-20, 5)) + ax.set(xticks=[], yticks=[]) + + Z = cbook.get_sample_data("axes_grid/bivariate_normal.npy") + extent = (-3, 4, -4, 3) + + axins = zoomed_inset_axes(ax, zoom=2, loc="upper left") + axins.set(xticks=[], yticks=[]) + + axins.imshow(Z, extent=extent, origin="lower") + + +def test_grid_with_axes_class_not_overriding_axis(): + Grid(plt.figure(), 111, (2, 2), axes_class=mpl.axes.Axes) + RGBAxes(plt.figure(), 111, axes_class=mpl.axes.Axes) diff --git a/lib/python3.12/site-packages/mpl_toolkits/axisartist/__init__.py b/lib/python3.12/site-packages/mpl_toolkits/axisartist/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..7b8d8c08ac226d105a5a54c5f21040cd25107ae6 --- /dev/null +++ b/lib/python3.12/site-packages/mpl_toolkits/axisartist/__init__.py @@ -0,0 +1,14 @@ +from .axislines import Axes +from .axislines import ( # noqa: F401 + AxesZero, AxisArtistHelper, AxisArtistHelperRectlinear, + GridHelperBase, GridHelperRectlinear, Subplot, SubplotZero) +from .axis_artist import AxisArtist, GridlinesCollection # noqa: F401 +from .grid_helper_curvelinear import GridHelperCurveLinear # noqa: F401 +from .floating_axes import FloatingAxes, FloatingSubplot # noqa: F401 +from mpl_toolkits.axes_grid1.parasite_axes import ( + host_axes_class_factory, parasite_axes_class_factory) + + +ParasiteAxes = parasite_axes_class_factory(Axes) +HostAxes = host_axes_class_factory(Axes) +SubplotHost = HostAxes diff --git a/lib/python3.12/site-packages/mpl_toolkits/axisartist/__pycache__/__init__.cpython-312.pyc b/lib/python3.12/site-packages/mpl_toolkits/axisartist/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ca612be7c161a081f1421f4ffbe42fea123b8fa1 Binary files /dev/null and b/lib/python3.12/site-packages/mpl_toolkits/axisartist/__pycache__/__init__.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/mpl_toolkits/axisartist/__pycache__/angle_helper.cpython-312.pyc b/lib/python3.12/site-packages/mpl_toolkits/axisartist/__pycache__/angle_helper.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0889ec8320b0098aac8b14906fd4ee9211fd1b45 Binary files /dev/null and b/lib/python3.12/site-packages/mpl_toolkits/axisartist/__pycache__/angle_helper.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/mpl_toolkits/axisartist/__pycache__/axes_divider.cpython-312.pyc b/lib/python3.12/site-packages/mpl_toolkits/axisartist/__pycache__/axes_divider.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a92ddd8ca1bafc7b2aedf1ad0afbd455503dffec Binary files /dev/null and b/lib/python3.12/site-packages/mpl_toolkits/axisartist/__pycache__/axes_divider.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/mpl_toolkits/axisartist/__pycache__/axis_artist.cpython-312.pyc b/lib/python3.12/site-packages/mpl_toolkits/axisartist/__pycache__/axis_artist.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..55f672e77badac1b78033bde241024cbd058872a Binary files /dev/null and b/lib/python3.12/site-packages/mpl_toolkits/axisartist/__pycache__/axis_artist.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/mpl_toolkits/axisartist/__pycache__/axisline_style.cpython-312.pyc b/lib/python3.12/site-packages/mpl_toolkits/axisartist/__pycache__/axisline_style.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..712d3768597a5b3e60ed7ec488b3cdbfebccfad7 Binary files /dev/null and b/lib/python3.12/site-packages/mpl_toolkits/axisartist/__pycache__/axisline_style.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/mpl_toolkits/axisartist/__pycache__/axislines.cpython-312.pyc b/lib/python3.12/site-packages/mpl_toolkits/axisartist/__pycache__/axislines.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f939132b51f337a442251c66822ab1a7406fd173 Binary files /dev/null and b/lib/python3.12/site-packages/mpl_toolkits/axisartist/__pycache__/axislines.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/mpl_toolkits/axisartist/__pycache__/floating_axes.cpython-312.pyc b/lib/python3.12/site-packages/mpl_toolkits/axisartist/__pycache__/floating_axes.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bcc3d6bf51f833537698260a8f23a012ad24508a Binary files /dev/null and b/lib/python3.12/site-packages/mpl_toolkits/axisartist/__pycache__/floating_axes.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/mpl_toolkits/axisartist/__pycache__/grid_finder.cpython-312.pyc b/lib/python3.12/site-packages/mpl_toolkits/axisartist/__pycache__/grid_finder.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f08c4ee8eadc1cce4359be4f58b3b29492b98f73 Binary files /dev/null and b/lib/python3.12/site-packages/mpl_toolkits/axisartist/__pycache__/grid_finder.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/mpl_toolkits/axisartist/__pycache__/grid_helper_curvelinear.cpython-312.pyc b/lib/python3.12/site-packages/mpl_toolkits/axisartist/__pycache__/grid_helper_curvelinear.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..afbc8a57a2bb502c652497cbb88caf8761bbbb14 Binary files /dev/null and b/lib/python3.12/site-packages/mpl_toolkits/axisartist/__pycache__/grid_helper_curvelinear.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/mpl_toolkits/axisartist/__pycache__/parasite_axes.cpython-312.pyc b/lib/python3.12/site-packages/mpl_toolkits/axisartist/__pycache__/parasite_axes.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..54910244bd4342c02cfacf4d5d5ac86ce7b9e60b Binary files /dev/null and b/lib/python3.12/site-packages/mpl_toolkits/axisartist/__pycache__/parasite_axes.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/mpl_toolkits/axisartist/angle_helper.py b/lib/python3.12/site-packages/mpl_toolkits/axisartist/angle_helper.py new file mode 100644 index 0000000000000000000000000000000000000000..1786cd70bcdb297de6c17374353cc2a49dfd0ae1 --- /dev/null +++ b/lib/python3.12/site-packages/mpl_toolkits/axisartist/angle_helper.py @@ -0,0 +1,394 @@ +import numpy as np +import math + +from mpl_toolkits.axisartist.grid_finder import ExtremeFinderSimple + + +def select_step_degree(dv): + + degree_limits_ = [1.5, 3, 7, 13, 20, 40, 70, 120, 270, 520] + degree_steps_ = [1, 2, 5, 10, 15, 30, 45, 90, 180, 360] + degree_factors = [1.] * len(degree_steps_) + + minsec_limits_ = [1.5, 2.5, 3.5, 8, 11, 18, 25, 45] + minsec_steps_ = [1, 2, 3, 5, 10, 15, 20, 30] + + minute_limits_ = np.array(minsec_limits_) / 60 + minute_factors = [60.] * len(minute_limits_) + + second_limits_ = np.array(minsec_limits_) / 3600 + second_factors = [3600.] * len(second_limits_) + + degree_limits = [*second_limits_, *minute_limits_, *degree_limits_] + degree_steps = [*minsec_steps_, *minsec_steps_, *degree_steps_] + degree_factors = [*second_factors, *minute_factors, *degree_factors] + + n = np.searchsorted(degree_limits, dv) + step = degree_steps[n] + factor = degree_factors[n] + + return step, factor + + +def select_step_hour(dv): + + hour_limits_ = [1.5, 2.5, 3.5, 5, 7, 10, 15, 21, 36] + hour_steps_ = [1, 2, 3, 4, 6, 8, 12, 18, 24] + hour_factors = [1.] * len(hour_steps_) + + minsec_limits_ = [1.5, 2.5, 3.5, 4.5, 5.5, 8, 11, 14, 18, 25, 45] + minsec_steps_ = [1, 2, 3, 4, 5, 6, 10, 12, 15, 20, 30] + + minute_limits_ = np.array(minsec_limits_) / 60 + minute_factors = [60.] * len(minute_limits_) + + second_limits_ = np.array(minsec_limits_) / 3600 + second_factors = [3600.] * len(second_limits_) + + hour_limits = [*second_limits_, *minute_limits_, *hour_limits_] + hour_steps = [*minsec_steps_, *minsec_steps_, *hour_steps_] + hour_factors = [*second_factors, *minute_factors, *hour_factors] + + n = np.searchsorted(hour_limits, dv) + step = hour_steps[n] + factor = hour_factors[n] + + return step, factor + + +def select_step_sub(dv): + + # subarcsec or degree + tmp = 10.**(int(math.log10(dv))-1.) + + factor = 1./tmp + + if 1.5*tmp >= dv: + step = 1 + elif 3.*tmp >= dv: + step = 2 + elif 7.*tmp >= dv: + step = 5 + else: + step = 1 + factor = 0.1*factor + + return step, factor + + +def select_step(v1, v2, nv, hour=False, include_last=True, + threshold_factor=3600.): + + if v1 > v2: + v1, v2 = v2, v1 + + dv = (v2 - v1) / nv + + if hour: + _select_step = select_step_hour + cycle = 24. + else: + _select_step = select_step_degree + cycle = 360. + + # for degree + if dv > 1 / threshold_factor: + step, factor = _select_step(dv) + else: + step, factor = select_step_sub(dv*threshold_factor) + + factor = factor * threshold_factor + + levs = np.arange(np.floor(v1 * factor / step), + np.ceil(v2 * factor / step) + 0.5, + dtype=int) * step + + # n : number of valid levels. If there is a cycle, e.g., [0, 90, 180, + # 270, 360], the grid line needs to be extended from 0 to 360, so + # we need to return the whole array. However, the last level (360) + # needs to be ignored often. In this case, so we return n=4. + + n = len(levs) + + # we need to check the range of values + # for example, -90 to 90, 0 to 360, + + if factor == 1. and levs[-1] >= levs[0] + cycle: # check for cycle + nv = int(cycle / step) + if include_last: + levs = levs[0] + np.arange(0, nv+1, 1) * step + else: + levs = levs[0] + np.arange(0, nv, 1) * step + + n = len(levs) + + return np.array(levs), n, factor + + +def select_step24(v1, v2, nv, include_last=True, threshold_factor=3600): + v1, v2 = v1 / 15, v2 / 15 + levs, n, factor = select_step(v1, v2, nv, hour=True, + include_last=include_last, + threshold_factor=threshold_factor) + return levs * 15, n, factor + + +def select_step360(v1, v2, nv, include_last=True, threshold_factor=3600): + return select_step(v1, v2, nv, hour=False, + include_last=include_last, + threshold_factor=threshold_factor) + + +class LocatorBase: + def __init__(self, nbins, include_last=True): + self.nbins = nbins + self._include_last = include_last + + def set_params(self, nbins=None): + if nbins is not None: + self.nbins = int(nbins) + + +class LocatorHMS(LocatorBase): + def __call__(self, v1, v2): + return select_step24(v1, v2, self.nbins, self._include_last) + + +class LocatorHM(LocatorBase): + def __call__(self, v1, v2): + return select_step24(v1, v2, self.nbins, self._include_last, + threshold_factor=60) + + +class LocatorH(LocatorBase): + def __call__(self, v1, v2): + return select_step24(v1, v2, self.nbins, self._include_last, + threshold_factor=1) + + +class LocatorDMS(LocatorBase): + def __call__(self, v1, v2): + return select_step360(v1, v2, self.nbins, self._include_last) + + +class LocatorDM(LocatorBase): + def __call__(self, v1, v2): + return select_step360(v1, v2, self.nbins, self._include_last, + threshold_factor=60) + + +class LocatorD(LocatorBase): + def __call__(self, v1, v2): + return select_step360(v1, v2, self.nbins, self._include_last, + threshold_factor=1) + + +class FormatterDMS: + deg_mark = r"^{\circ}" + min_mark = r"^{\prime}" + sec_mark = r"^{\prime\prime}" + + fmt_d = "$%d" + deg_mark + "$" + fmt_ds = r"$%d.%s" + deg_mark + "$" + + # %s for sign + fmt_d_m = r"$%s%d" + deg_mark + r"\,%02d" + min_mark + "$" + fmt_d_ms = r"$%s%d" + deg_mark + r"\,%02d.%s" + min_mark + "$" + + fmt_d_m_partial = "$%s%d" + deg_mark + r"\,%02d" + min_mark + r"\," + fmt_s_partial = "%02d" + sec_mark + "$" + fmt_ss_partial = "%02d.%s" + sec_mark + "$" + + def _get_number_fraction(self, factor): + ## check for fractional numbers + number_fraction = None + # check for 60 + + for threshold in [1, 60, 3600]: + if factor <= threshold: + break + + d = factor // threshold + int_log_d = int(np.floor(np.log10(d))) + if 10**int_log_d == d and d != 1: + number_fraction = int_log_d + factor = factor // 10**int_log_d + return factor, number_fraction + + return factor, number_fraction + + def __call__(self, direction, factor, values): + if len(values) == 0: + return [] + + ss = np.sign(values) + signs = ["-" if v < 0 else "" for v in values] + + factor, number_fraction = self._get_number_fraction(factor) + + values = np.abs(values) + + if number_fraction is not None: + values, frac_part = divmod(values, 10 ** number_fraction) + frac_fmt = "%%0%dd" % (number_fraction,) + frac_str = [frac_fmt % (f1,) for f1 in frac_part] + + if factor == 1: + if number_fraction is None: + return [self.fmt_d % (s * int(v),) for s, v in zip(ss, values)] + else: + return [self.fmt_ds % (s * int(v), f1) + for s, v, f1 in zip(ss, values, frac_str)] + elif factor == 60: + deg_part, min_part = divmod(values, 60) + if number_fraction is None: + return [self.fmt_d_m % (s1, d1, m1) + for s1, d1, m1 in zip(signs, deg_part, min_part)] + else: + return [self.fmt_d_ms % (s, d1, m1, f1) + for s, d1, m1, f1 + in zip(signs, deg_part, min_part, frac_str)] + + elif factor == 3600: + if ss[-1] == -1: + inverse_order = True + values = values[::-1] + signs = signs[::-1] + else: + inverse_order = False + + l_hm_old = "" + r = [] + + deg_part, min_part_ = divmod(values, 3600) + min_part, sec_part = divmod(min_part_, 60) + + if number_fraction is None: + sec_str = [self.fmt_s_partial % (s1,) for s1 in sec_part] + else: + sec_str = [self.fmt_ss_partial % (s1, f1) + for s1, f1 in zip(sec_part, frac_str)] + + for s, d1, m1, s1 in zip(signs, deg_part, min_part, sec_str): + l_hm = self.fmt_d_m_partial % (s, d1, m1) + if l_hm != l_hm_old: + l_hm_old = l_hm + l = l_hm + s1 + else: + l = "$" + s + s1 + r.append(l) + + if inverse_order: + return r[::-1] + else: + return r + + else: # factor > 3600. + return [r"$%s^{\circ}$" % v for v in ss*values] + + +class FormatterHMS(FormatterDMS): + deg_mark = r"^\mathrm{h}" + min_mark = r"^\mathrm{m}" + sec_mark = r"^\mathrm{s}" + + fmt_d = "$%d" + deg_mark + "$" + fmt_ds = r"$%d.%s" + deg_mark + "$" + + # %s for sign + fmt_d_m = r"$%s%d" + deg_mark + r"\,%02d" + min_mark+"$" + fmt_d_ms = r"$%s%d" + deg_mark + r"\,%02d.%s" + min_mark+"$" + + fmt_d_m_partial = "$%s%d" + deg_mark + r"\,%02d" + min_mark + r"\," + fmt_s_partial = "%02d" + sec_mark + "$" + fmt_ss_partial = "%02d.%s" + sec_mark + "$" + + def __call__(self, direction, factor, values): # hour + return super().__call__(direction, factor, np.asarray(values) / 15) + + +class ExtremeFinderCycle(ExtremeFinderSimple): + # docstring inherited + + def __init__(self, nx, ny, + lon_cycle=360., lat_cycle=None, + lon_minmax=None, lat_minmax=(-90, 90)): + """ + This subclass handles the case where one or both coordinates should be + taken modulo 360, or be restricted to not exceed a specific range. + + Parameters + ---------- + nx, ny : int + The number of samples in each direction. + + lon_cycle, lat_cycle : 360 or None + If not None, values in the corresponding direction are taken modulo + *lon_cycle* or *lat_cycle*; in theory this can be any number but + the implementation actually assumes that it is 360 (if not None); + other values give nonsensical results. + + This is done by "unwrapping" the transformed grid coordinates so + that jumps are less than a half-cycle; then normalizing the span to + no more than a full cycle. + + For example, if values are in the union of the [0, 2] and + [358, 360] intervals (typically, angles measured modulo 360), the + values in the second interval are normalized to [-2, 0] instead so + that the values now cover [-2, 2]. If values are in a range of + [5, 1000], this gets normalized to [5, 365]. + + lon_minmax, lat_minmax : (float, float) or None + If not None, the computed bounding box is clipped to the given + range in the corresponding direction. + """ + self.nx, self.ny = nx, ny + self.lon_cycle, self.lat_cycle = lon_cycle, lat_cycle + self.lon_minmax = lon_minmax + self.lat_minmax = lat_minmax + + def __call__(self, transform_xy, x1, y1, x2, y2): + # docstring inherited + x, y = np.meshgrid( + np.linspace(x1, x2, self.nx), np.linspace(y1, y2, self.ny)) + lon, lat = transform_xy(np.ravel(x), np.ravel(y)) + + # iron out jumps, but algorithm should be improved. + # This is just naive way of doing and my fail for some cases. + # Consider replacing this with numpy.unwrap + # We are ignoring invalid warnings. They are triggered when + # comparing arrays with NaNs using > We are already handling + # that correctly using np.nanmin and np.nanmax + with np.errstate(invalid='ignore'): + if self.lon_cycle is not None: + lon0 = np.nanmin(lon) + lon -= 360. * ((lon - lon0) > 180.) + if self.lat_cycle is not None: + lat0 = np.nanmin(lat) + lat -= 360. * ((lat - lat0) > 180.) + + lon_min, lon_max = np.nanmin(lon), np.nanmax(lon) + lat_min, lat_max = np.nanmin(lat), np.nanmax(lat) + + lon_min, lon_max, lat_min, lat_max = \ + self._add_pad(lon_min, lon_max, lat_min, lat_max) + + # check cycle + if self.lon_cycle: + lon_max = min(lon_max, lon_min + self.lon_cycle) + if self.lat_cycle: + lat_max = min(lat_max, lat_min + self.lat_cycle) + + if self.lon_minmax is not None: + min0 = self.lon_minmax[0] + lon_min = max(min0, lon_min) + max0 = self.lon_minmax[1] + lon_max = min(max0, lon_max) + + if self.lat_minmax is not None: + min0 = self.lat_minmax[0] + lat_min = max(min0, lat_min) + max0 = self.lat_minmax[1] + lat_max = min(max0, lat_max) + + return lon_min, lon_max, lat_min, lat_max diff --git a/lib/python3.12/site-packages/mpl_toolkits/axisartist/axes_divider.py b/lib/python3.12/site-packages/mpl_toolkits/axisartist/axes_divider.py new file mode 100644 index 0000000000000000000000000000000000000000..d0392be782d9c06404c3d83c2b8ca271bbb8fa72 --- /dev/null +++ b/lib/python3.12/site-packages/mpl_toolkits/axisartist/axes_divider.py @@ -0,0 +1,2 @@ +from mpl_toolkits.axes_grid1.axes_divider import ( # noqa + Divider, SubplotDivider, AxesDivider, make_axes_locatable) diff --git a/lib/python3.12/site-packages/mpl_toolkits/axisartist/axis_artist.py b/lib/python3.12/site-packages/mpl_toolkits/axisartist/axis_artist.py new file mode 100644 index 0000000000000000000000000000000000000000..725c665d818ade7772b427114975a0b1bfcec3b0 --- /dev/null +++ b/lib/python3.12/site-packages/mpl_toolkits/axisartist/axis_artist.py @@ -0,0 +1,1116 @@ +""" +The :mod:`.axis_artist` module implements custom artists to draw axis elements +(axis lines and labels, tick lines and labels, grid lines). + +Axis lines and labels and tick lines and labels are managed by the `AxisArtist` +class; grid lines are managed by the `GridlinesCollection` class. + +There is one `AxisArtist` per Axis; it can be accessed through +the ``axis`` dictionary of the parent Axes (which should be a +`mpl_toolkits.axislines.Axes`), e.g. ``ax.axis["bottom"]``. + +Children of the AxisArtist are accessed as attributes: ``.line`` and ``.label`` +for the axis line and label, ``.major_ticks``, ``.major_ticklabels``, +``.minor_ticks``, ``.minor_ticklabels`` for the tick lines and labels (e.g. +``ax.axis["bottom"].line``). + +Children properties (colors, fonts, line widths, etc.) can be set using +setters, e.g. :: + + # Make the major ticks of the bottom axis red. + ax.axis["bottom"].major_ticks.set_color("red") + +However, things like the locations of ticks, and their ticklabels need to be +changed from the side of the grid_helper. + +axis_direction +-------------- + +`AxisArtist`, `AxisLabel`, `TickLabels` have an *axis_direction* attribute, +which adjusts the location, angle, etc. The *axis_direction* must be one of +"left", "right", "bottom", "top", and follows the Matplotlib convention for +rectangular axis. + +For example, for the *bottom* axis (the left and right is relative to the +direction of the increasing coordinate), + +* ticklabels and axislabel are on the right +* ticklabels and axislabel have text angle of 0 +* ticklabels are baseline, center-aligned +* axislabel is top, center-aligned + +The text angles are actually relative to (90 + angle of the direction to the +ticklabel), which gives 0 for bottom axis. + +=================== ====== ======== ====== ======== +Property left bottom right top +=================== ====== ======== ====== ======== +ticklabel location left right right left +axislabel location left right right left +ticklabel angle 90 0 -90 180 +axislabel angle 180 0 0 180 +ticklabel va center baseline center baseline +axislabel va center top center bottom +ticklabel ha right center right center +axislabel ha right center right center +=================== ====== ======== ====== ======== + +Ticks are by default direct opposite side of the ticklabels. To make ticks to +the same side of the ticklabels, :: + + ax.axis["bottom"].major_ticks.set_tick_out(True) + +The following attributes can be customized (use the ``set_xxx`` methods): + +* `Ticks`: ticksize, tick_out +* `TickLabels`: pad +* `AxisLabel`: pad +""" + +# FIXME : +# angles are given in data coordinate - need to convert it to canvas coordinate + + +from operator import methodcaller + +import numpy as np + +import matplotlib as mpl +from matplotlib import _api, cbook +import matplotlib.artist as martist +import matplotlib.colors as mcolors +import matplotlib.text as mtext +from matplotlib.collections import LineCollection +from matplotlib.lines import Line2D +from matplotlib.patches import PathPatch +from matplotlib.path import Path +from matplotlib.transforms import ( + Affine2D, Bbox, IdentityTransform, ScaledTranslation) + +from .axisline_style import AxislineStyle + + +class AttributeCopier: + def get_ref_artist(self): + """ + Return the underlying artist that actually defines some properties + (e.g., color) of this artist. + """ + raise RuntimeError("get_ref_artist must overridden") + + def get_attribute_from_ref_artist(self, attr_name): + getter = methodcaller("get_" + attr_name) + prop = getter(super()) + return getter(self.get_ref_artist()) if prop == "auto" else prop + + +class Ticks(AttributeCopier, Line2D): + """ + Ticks are derived from `.Line2D`, and note that ticks themselves + are markers. Thus, you should use set_mec, set_mew, etc. + + To change the tick size (length), you need to use + `set_ticksize`. To change the direction of the ticks (ticks are + in opposite direction of ticklabels by default), use + ``set_tick_out(False)`` + """ + + def __init__(self, ticksize, tick_out=False, *, axis=None, **kwargs): + self._ticksize = ticksize + self.locs_angles_labels = [] + + self.set_tick_out(tick_out) + + self._axis = axis + if self._axis is not None: + if "color" not in kwargs: + kwargs["color"] = "auto" + if "mew" not in kwargs and "markeredgewidth" not in kwargs: + kwargs["markeredgewidth"] = "auto" + + Line2D.__init__(self, [0.], [0.], **kwargs) + self.set_snap(True) + + def get_ref_artist(self): + # docstring inherited + return self._axis.majorTicks[0].tick1line + + def set_color(self, color): + # docstring inherited + # Unlike the base Line2D.set_color, this also supports "auto". + if not cbook._str_equal(color, "auto"): + mcolors._check_color_like(color=color) + self._color = color + self.stale = True + + def get_color(self): + return self.get_attribute_from_ref_artist("color") + + def get_markeredgecolor(self): + return self.get_attribute_from_ref_artist("markeredgecolor") + + def get_markeredgewidth(self): + return self.get_attribute_from_ref_artist("markeredgewidth") + + def set_tick_out(self, b): + """Set whether ticks are drawn inside or outside the axes.""" + self._tick_out = b + + def get_tick_out(self): + """Return whether ticks are drawn inside or outside the axes.""" + return self._tick_out + + def set_ticksize(self, ticksize): + """Set length of the ticks in points.""" + self._ticksize = ticksize + + def get_ticksize(self): + """Return length of the ticks in points.""" + return self._ticksize + + def set_locs_angles(self, locs_angles): + self.locs_angles = locs_angles + + _tickvert_path = Path([[0., 0.], [1., 0.]]) + + def draw(self, renderer): + if not self.get_visible(): + return + + gc = renderer.new_gc() + gc.set_foreground(self.get_markeredgecolor()) + gc.set_linewidth(self.get_markeredgewidth()) + gc.set_alpha(self._alpha) + + path_trans = self.get_transform() + marker_transform = (Affine2D() + .scale(renderer.points_to_pixels(self._ticksize))) + if self.get_tick_out(): + marker_transform.rotate_deg(180) + + for loc, angle in self.locs_angles: + locs = path_trans.transform_non_affine(np.array([loc])) + if self.axes and not self.axes.viewLim.contains(*locs[0]): + continue + renderer.draw_markers( + gc, self._tickvert_path, + marker_transform + Affine2D().rotate_deg(angle), + Path(locs), path_trans.get_affine()) + + gc.restore() + + +class LabelBase(mtext.Text): + """ + A base class for `.AxisLabel` and `.TickLabels`. The position and + angle of the text are calculated by the offset_ref_angle, + text_ref_angle, and offset_radius attributes. + """ + + def __init__(self, *args, **kwargs): + self.locs_angles_labels = [] + self._ref_angle = 0 + self._offset_radius = 0. + + super().__init__(*args, **kwargs) + + self.set_rotation_mode("anchor") + self._text_follow_ref_angle = True + + @property + def _text_ref_angle(self): + if self._text_follow_ref_angle: + return self._ref_angle + 90 + else: + return 0 + + @property + def _offset_ref_angle(self): + return self._ref_angle + + _get_opposite_direction = {"left": "right", + "right": "left", + "top": "bottom", + "bottom": "top"}.__getitem__ + + def draw(self, renderer): + if not self.get_visible(): + return + + # save original and adjust some properties + tr = self.get_transform() + angle_orig = self.get_rotation() + theta = np.deg2rad(self._offset_ref_angle) + dd = self._offset_radius + dx, dy = dd * np.cos(theta), dd * np.sin(theta) + + self.set_transform(tr + Affine2D().translate(dx, dy)) + self.set_rotation(self._text_ref_angle + angle_orig) + super().draw(renderer) + # restore original properties + self.set_transform(tr) + self.set_rotation(angle_orig) + + def get_window_extent(self, renderer=None): + if renderer is None: + renderer = self.get_figure(root=True)._get_renderer() + + # save original and adjust some properties + tr = self.get_transform() + angle_orig = self.get_rotation() + theta = np.deg2rad(self._offset_ref_angle) + dd = self._offset_radius + dx, dy = dd * np.cos(theta), dd * np.sin(theta) + + self.set_transform(tr + Affine2D().translate(dx, dy)) + self.set_rotation(self._text_ref_angle + angle_orig) + bbox = super().get_window_extent(renderer).frozen() + # restore original properties + self.set_transform(tr) + self.set_rotation(angle_orig) + + return bbox + + +class AxisLabel(AttributeCopier, LabelBase): + """ + Axis label. Derived from `.Text`. The position of the text is updated + in the fly, so changing text position has no effect. Otherwise, the + properties can be changed as a normal `.Text`. + + To change the pad between tick labels and axis label, use `set_pad`. + """ + + def __init__(self, *args, axis_direction="bottom", axis=None, **kwargs): + self._axis = axis + self._pad = 5 + self._external_pad = 0 # in pixels + LabelBase.__init__(self, *args, **kwargs) + self.set_axis_direction(axis_direction) + + def set_pad(self, pad): + """ + Set the internal pad in points. + + The actual pad will be the sum of the internal pad and the + external pad (the latter is set automatically by the `.AxisArtist`). + + Parameters + ---------- + pad : float + The internal pad in points. + """ + self._pad = pad + + def get_pad(self): + """ + Return the internal pad in points. + + See `.set_pad` for more details. + """ + return self._pad + + def get_ref_artist(self): + # docstring inherited + return self._axis.label + + def get_text(self): + # docstring inherited + t = super().get_text() + if t == "__from_axes__": + return self._axis.label.get_text() + return self._text + + _default_alignments = dict(left=("bottom", "center"), + right=("top", "center"), + bottom=("top", "center"), + top=("bottom", "center")) + + def set_default_alignment(self, d): + """ + Set the default alignment. See `set_axis_direction` for details. + + Parameters + ---------- + d : {"left", "bottom", "right", "top"} + """ + va, ha = _api.check_getitem(self._default_alignments, d=d) + self.set_va(va) + self.set_ha(ha) + + _default_angles = dict(left=180, + right=0, + bottom=0, + top=180) + + def set_default_angle(self, d): + """ + Set the default angle. See `set_axis_direction` for details. + + Parameters + ---------- + d : {"left", "bottom", "right", "top"} + """ + self.set_rotation(_api.check_getitem(self._default_angles, d=d)) + + def set_axis_direction(self, d): + """ + Adjust the text angle and text alignment of axis label + according to the matplotlib convention. + + ===================== ========== ========= ========== ========== + Property left bottom right top + ===================== ========== ========= ========== ========== + axislabel angle 180 0 0 180 + axislabel va center top center bottom + axislabel ha right center right center + ===================== ========== ========= ========== ========== + + Note that the text angles are actually relative to (90 + angle + of the direction to the ticklabel), which gives 0 for bottom + axis. + + Parameters + ---------- + d : {"left", "bottom", "right", "top"} + """ + self.set_default_alignment(d) + self.set_default_angle(d) + + def get_color(self): + return self.get_attribute_from_ref_artist("color") + + def draw(self, renderer): + if not self.get_visible(): + return + + self._offset_radius = \ + self._external_pad + renderer.points_to_pixels(self.get_pad()) + + super().draw(renderer) + + def get_window_extent(self, renderer=None): + if renderer is None: + renderer = self.get_figure(root=True)._get_renderer() + if not self.get_visible(): + return + + r = self._external_pad + renderer.points_to_pixels(self.get_pad()) + self._offset_radius = r + + bb = super().get_window_extent(renderer) + + return bb + + +class TickLabels(AxisLabel): # mtext.Text + """ + Tick labels. While derived from `.Text`, this single artist draws all + ticklabels. As in `.AxisLabel`, the position of the text is updated + in the fly, so changing text position has no effect. Otherwise, + the properties can be changed as a normal `.Text`. Unlike the + ticklabels of the mainline Matplotlib, properties of a single + ticklabel alone cannot be modified. + + To change the pad between ticks and ticklabels, use `~.AxisLabel.set_pad`. + """ + + def __init__(self, *, axis_direction="bottom", **kwargs): + super().__init__(**kwargs) + self.set_axis_direction(axis_direction) + self._axislabel_pad = 0 + + def get_ref_artist(self): + # docstring inherited + return self._axis.get_ticklabels()[0] + + def set_axis_direction(self, label_direction): + """ + Adjust the text angle and text alignment of ticklabels + according to the Matplotlib convention. + + The *label_direction* must be one of [left, right, bottom, top]. + + ===================== ========== ========= ========== ========== + Property left bottom right top + ===================== ========== ========= ========== ========== + ticklabel angle 90 0 -90 180 + ticklabel va center baseline center baseline + ticklabel ha right center right center + ===================== ========== ========= ========== ========== + + Note that the text angles are actually relative to (90 + angle + of the direction to the ticklabel), which gives 0 for bottom + axis. + + Parameters + ---------- + label_direction : {"left", "bottom", "right", "top"} + + """ + self.set_default_alignment(label_direction) + self.set_default_angle(label_direction) + self._axis_direction = label_direction + + def invert_axis_direction(self): + label_direction = self._get_opposite_direction(self._axis_direction) + self.set_axis_direction(label_direction) + + def _get_ticklabels_offsets(self, renderer, label_direction): + """ + Calculate the ticklabel offsets from the tick and their total heights. + + The offset only takes account the offset due to the vertical alignment + of the ticklabels: if axis direction is bottom and va is 'top', it will + return 0; if va is 'baseline', it will return (height-descent). + """ + whd_list = self.get_texts_widths_heights_descents(renderer) + + if not whd_list: + return 0, 0 + + r = 0 + va, ha = self.get_va(), self.get_ha() + + if label_direction == "left": + pad = max(w for w, h, d in whd_list) + if ha == "left": + r = pad + elif ha == "center": + r = .5 * pad + elif label_direction == "right": + pad = max(w for w, h, d in whd_list) + if ha == "right": + r = pad + elif ha == "center": + r = .5 * pad + elif label_direction == "bottom": + pad = max(h for w, h, d in whd_list) + if va == "bottom": + r = pad + elif va == "center": + r = .5 * pad + elif va == "baseline": + max_ascent = max(h - d for w, h, d in whd_list) + max_descent = max(d for w, h, d in whd_list) + r = max_ascent + pad = max_ascent + max_descent + elif label_direction == "top": + pad = max(h for w, h, d in whd_list) + if va == "top": + r = pad + elif va == "center": + r = .5 * pad + elif va == "baseline": + max_ascent = max(h - d for w, h, d in whd_list) + max_descent = max(d for w, h, d in whd_list) + r = max_descent + pad = max_ascent + max_descent + + # r : offset + # pad : total height of the ticklabels. This will be used to + # calculate the pad for the axislabel. + return r, pad + + _default_alignments = dict(left=("center", "right"), + right=("center", "left"), + bottom=("baseline", "center"), + top=("baseline", "center")) + + _default_angles = dict(left=90, + right=-90, + bottom=0, + top=180) + + def draw(self, renderer): + if not self.get_visible(): + self._axislabel_pad = self._external_pad + return + + r, total_width = self._get_ticklabels_offsets(renderer, + self._axis_direction) + + pad = self._external_pad + renderer.points_to_pixels(self.get_pad()) + self._offset_radius = r + pad + + for (x, y), a, l in self._locs_angles_labels: + if not l.strip(): + continue + self._ref_angle = a + self.set_x(x) + self.set_y(y) + self.set_text(l) + LabelBase.draw(self, renderer) + + # the value saved will be used to draw axislabel. + self._axislabel_pad = total_width + pad + + def set_locs_angles_labels(self, locs_angles_labels): + self._locs_angles_labels = locs_angles_labels + + def get_window_extents(self, renderer=None): + if renderer is None: + renderer = self.get_figure(root=True)._get_renderer() + + if not self.get_visible(): + self._axislabel_pad = self._external_pad + return [] + + bboxes = [] + + r, total_width = self._get_ticklabels_offsets(renderer, + self._axis_direction) + + pad = self._external_pad + renderer.points_to_pixels(self.get_pad()) + self._offset_radius = r + pad + + for (x, y), a, l in self._locs_angles_labels: + self._ref_angle = a + self.set_x(x) + self.set_y(y) + self.set_text(l) + bb = LabelBase.get_window_extent(self, renderer) + bboxes.append(bb) + + # the value saved will be used to draw axislabel. + self._axislabel_pad = total_width + pad + + return bboxes + + def get_texts_widths_heights_descents(self, renderer): + """ + Return a list of ``(width, height, descent)`` tuples for ticklabels. + + Empty labels are left out. + """ + whd_list = [] + for _loc, _angle, label in self._locs_angles_labels: + if not label.strip(): + continue + clean_line, ismath = self._preprocess_math(label) + whd = mtext._get_text_metrics_with_cache( + renderer, clean_line, self._fontproperties, ismath=ismath, + dpi=self.get_figure(root=True).dpi) + whd_list.append(whd) + return whd_list + + +class GridlinesCollection(LineCollection): + def __init__(self, *args, which="major", axis="both", **kwargs): + """ + Collection of grid lines. + + Parameters + ---------- + which : {"major", "minor"} + Which grid to consider. + axis : {"both", "x", "y"} + Which axis to consider. + *args, **kwargs + Passed to `.LineCollection`. + """ + self._which = which + self._axis = axis + super().__init__(*args, **kwargs) + self.set_grid_helper(None) + + def set_which(self, which): + """ + Select major or minor grid lines. + + Parameters + ---------- + which : {"major", "minor"} + """ + self._which = which + + def set_axis(self, axis): + """ + Select axis. + + Parameters + ---------- + axis : {"both", "x", "y"} + """ + self._axis = axis + + def set_grid_helper(self, grid_helper): + """ + Set grid helper. + + Parameters + ---------- + grid_helper : `.GridHelperBase` subclass + """ + self._grid_helper = grid_helper + + def draw(self, renderer): + if self._grid_helper is not None: + self._grid_helper.update_lim(self.axes) + gl = self._grid_helper.get_gridlines(self._which, self._axis) + self.set_segments([np.transpose(l) for l in gl]) + super().draw(renderer) + + +class AxisArtist(martist.Artist): + """ + An artist which draws axis (a line along which the n-th axes coord + is constant) line, ticks, tick labels, and axis label. + """ + + zorder = 2.5 + + @property + def LABELPAD(self): + return self.label.get_pad() + + @LABELPAD.setter + def LABELPAD(self, v): + self.label.set_pad(v) + + def __init__(self, axes, + helper, + offset=None, + axis_direction="bottom", + **kwargs): + """ + Parameters + ---------- + axes : `mpl_toolkits.axisartist.axislines.Axes` + helper : `~mpl_toolkits.axisartist.axislines.AxisArtistHelper` + """ + # axes is also used to follow the axis attribute (tick color, etc). + + super().__init__(**kwargs) + + self.axes = axes + + self._axis_artist_helper = helper + + if offset is None: + offset = (0, 0) + self.offset_transform = ScaledTranslation( + *offset, + Affine2D().scale(1 / 72) # points to inches. + + self.axes.get_figure(root=False).dpi_scale_trans) + + if axis_direction in ["left", "right"]: + self.axis = axes.yaxis + else: + self.axis = axes.xaxis + + self._axisline_style = None + self._axis_direction = axis_direction + + self._init_line() + self._init_ticks(**kwargs) + self._init_offsetText(axis_direction) + self._init_label() + + # axis direction + self._ticklabel_add_angle = 0. + self._axislabel_add_angle = 0. + self.set_axis_direction(axis_direction) + + # axis direction + + def set_axis_direction(self, axis_direction): + """ + Adjust the direction, text angle, and text alignment of tick labels + and axis labels following the Matplotlib convention for the rectangle + axes. + + The *axis_direction* must be one of [left, right, bottom, top]. + + ===================== ========== ========= ========== ========== + Property left bottom right top + ===================== ========== ========= ========== ========== + ticklabel direction "-" "+" "+" "-" + axislabel direction "-" "+" "+" "-" + ticklabel angle 90 0 -90 180 + ticklabel va center baseline center baseline + ticklabel ha right center right center + axislabel angle 180 0 0 180 + axislabel va center top center bottom + axislabel ha right center right center + ===================== ========== ========= ========== ========== + + Note that the direction "+" and "-" are relative to the direction of + the increasing coordinate. Also, the text angles are actually + relative to (90 + angle of the direction to the ticklabel), + which gives 0 for bottom axis. + + Parameters + ---------- + axis_direction : {"left", "bottom", "right", "top"} + """ + self.major_ticklabels.set_axis_direction(axis_direction) + self.label.set_axis_direction(axis_direction) + self._axis_direction = axis_direction + if axis_direction in ["left", "top"]: + self.set_ticklabel_direction("-") + self.set_axislabel_direction("-") + else: + self.set_ticklabel_direction("+") + self.set_axislabel_direction("+") + + def set_ticklabel_direction(self, tick_direction): + r""" + Adjust the direction of the tick labels. + + Note that the *tick_direction*\s '+' and '-' are relative to the + direction of the increasing coordinate. + + Parameters + ---------- + tick_direction : {"+", "-"} + """ + self._ticklabel_add_angle = _api.check_getitem( + {"+": 0, "-": 180}, tick_direction=tick_direction) + + def invert_ticklabel_direction(self): + self._ticklabel_add_angle = (self._ticklabel_add_angle + 180) % 360 + self.major_ticklabels.invert_axis_direction() + self.minor_ticklabels.invert_axis_direction() + + def set_axislabel_direction(self, label_direction): + r""" + Adjust the direction of the axis label. + + Note that the *label_direction*\s '+' and '-' are relative to the + direction of the increasing coordinate. + + Parameters + ---------- + label_direction : {"+", "-"} + """ + self._axislabel_add_angle = _api.check_getitem( + {"+": 0, "-": 180}, label_direction=label_direction) + + def get_transform(self): + return self.axes.transAxes + self.offset_transform + + def get_helper(self): + """ + Return axis artist helper instance. + """ + return self._axis_artist_helper + + def set_axisline_style(self, axisline_style=None, **kwargs): + """ + Set the axisline style. + + The new style is completely defined by the passed attributes. Existing + style attributes are forgotten. + + Parameters + ---------- + axisline_style : str or None + The line style, e.g. '->', optionally followed by a comma-separated + list of attributes. Alternatively, the attributes can be provided + as keywords. + + If *None* this returns a string containing the available styles. + + Examples + -------- + The following two commands are equal: + + >>> set_axisline_style("->,size=1.5") + >>> set_axisline_style("->", size=1.5) + """ + if axisline_style is None: + return AxislineStyle.pprint_styles() + + if isinstance(axisline_style, AxislineStyle._Base): + self._axisline_style = axisline_style + else: + self._axisline_style = AxislineStyle(axisline_style, **kwargs) + + self._init_line() + + def get_axisline_style(self): + """Return the current axisline style.""" + return self._axisline_style + + def _init_line(self): + """ + Initialize the *line* artist that is responsible to draw the axis line. + """ + tran = (self._axis_artist_helper.get_line_transform(self.axes) + + self.offset_transform) + + axisline_style = self.get_axisline_style() + if axisline_style is None: + self.line = PathPatch( + self._axis_artist_helper.get_line(self.axes), + color=mpl.rcParams['axes.edgecolor'], + fill=False, + linewidth=mpl.rcParams['axes.linewidth'], + capstyle=mpl.rcParams['lines.solid_capstyle'], + joinstyle=mpl.rcParams['lines.solid_joinstyle'], + transform=tran) + else: + self.line = axisline_style(self, transform=tran) + + def _draw_line(self, renderer): + self.line.set_path(self._axis_artist_helper.get_line(self.axes)) + if self.get_axisline_style() is not None: + self.line.set_line_mutation_scale(self.major_ticklabels.get_size()) + self.line.draw(renderer) + + def _init_ticks(self, **kwargs): + axis_name = self.axis.axis_name + + trans = (self._axis_artist_helper.get_tick_transform(self.axes) + + self.offset_transform) + + self.major_ticks = Ticks( + kwargs.get( + "major_tick_size", + mpl.rcParams[f"{axis_name}tick.major.size"]), + axis=self.axis, transform=trans) + self.minor_ticks = Ticks( + kwargs.get( + "minor_tick_size", + mpl.rcParams[f"{axis_name}tick.minor.size"]), + axis=self.axis, transform=trans) + + size = mpl.rcParams[f"{axis_name}tick.labelsize"] + self.major_ticklabels = TickLabels( + axis=self.axis, + axis_direction=self._axis_direction, + figure=self.axes.get_figure(root=False), + transform=trans, + fontsize=size, + pad=kwargs.get( + "major_tick_pad", mpl.rcParams[f"{axis_name}tick.major.pad"]), + ) + self.minor_ticklabels = TickLabels( + axis=self.axis, + axis_direction=self._axis_direction, + figure=self.axes.get_figure(root=False), + transform=trans, + fontsize=size, + pad=kwargs.get( + "minor_tick_pad", mpl.rcParams[f"{axis_name}tick.minor.pad"]), + ) + + def _get_tick_info(self, tick_iter): + """ + Return a pair of: + + - list of locs and angles for ticks + - list of locs, angles and labels for ticklabels. + """ + ticks_loc_angle = [] + ticklabels_loc_angle_label = [] + + ticklabel_add_angle = self._ticklabel_add_angle + + for loc, angle_normal, angle_tangent, label in tick_iter: + angle_label = angle_tangent - 90 + ticklabel_add_angle + angle_tick = (angle_normal + if 90 <= (angle_label - angle_normal) % 360 <= 270 + else angle_normal + 180) + ticks_loc_angle.append([loc, angle_tick]) + ticklabels_loc_angle_label.append([loc, angle_label, label]) + + return ticks_loc_angle, ticklabels_loc_angle_label + + def _update_ticks(self, renderer=None): + # set extra pad for major and minor ticklabels: use ticksize of + # majorticks even for minor ticks. not clear what is best. + + if renderer is None: + renderer = self.get_figure(root=True)._get_renderer() + + dpi_cor = renderer.points_to_pixels(1.) + if self.major_ticks.get_visible() and self.major_ticks.get_tick_out(): + ticklabel_pad = self.major_ticks._ticksize * dpi_cor + self.major_ticklabels._external_pad = ticklabel_pad + self.minor_ticklabels._external_pad = ticklabel_pad + else: + self.major_ticklabels._external_pad = 0 + self.minor_ticklabels._external_pad = 0 + + majortick_iter, minortick_iter = \ + self._axis_artist_helper.get_tick_iterators(self.axes) + + tick_loc_angle, ticklabel_loc_angle_label = \ + self._get_tick_info(majortick_iter) + self.major_ticks.set_locs_angles(tick_loc_angle) + self.major_ticklabels.set_locs_angles_labels(ticklabel_loc_angle_label) + + tick_loc_angle, ticklabel_loc_angle_label = \ + self._get_tick_info(minortick_iter) + self.minor_ticks.set_locs_angles(tick_loc_angle) + self.minor_ticklabels.set_locs_angles_labels(ticklabel_loc_angle_label) + + def _draw_ticks(self, renderer): + self._update_ticks(renderer) + self.major_ticks.draw(renderer) + self.major_ticklabels.draw(renderer) + self.minor_ticks.draw(renderer) + self.minor_ticklabels.draw(renderer) + if (self.major_ticklabels.get_visible() + or self.minor_ticklabels.get_visible()): + self._draw_offsetText(renderer) + + _offsetText_pos = dict(left=(0, 1, "bottom", "right"), + right=(1, 1, "bottom", "left"), + bottom=(1, 0, "top", "right"), + top=(1, 1, "bottom", "right")) + + def _init_offsetText(self, direction): + x, y, va, ha = self._offsetText_pos[direction] + self.offsetText = mtext.Annotation( + "", + xy=(x, y), xycoords="axes fraction", + xytext=(0, 0), textcoords="offset points", + color=mpl.rcParams['xtick.color'], + horizontalalignment=ha, verticalalignment=va, + ) + self.offsetText.set_transform(IdentityTransform()) + self.axes._set_artist_props(self.offsetText) + + def _update_offsetText(self): + self.offsetText.set_text(self.axis.major.formatter.get_offset()) + self.offsetText.set_size(self.major_ticklabels.get_size()) + offset = (self.major_ticklabels.get_pad() + + self.major_ticklabels.get_size() + + 2) + self.offsetText.xyann = (0, offset) + + def _draw_offsetText(self, renderer): + self._update_offsetText() + self.offsetText.draw(renderer) + + def _init_label(self, **kwargs): + tr = (self._axis_artist_helper.get_axislabel_transform(self.axes) + + self.offset_transform) + self.label = AxisLabel( + 0, 0, "__from_axes__", + color="auto", + fontsize=kwargs.get("labelsize", mpl.rcParams['axes.labelsize']), + fontweight=mpl.rcParams['axes.labelweight'], + axis=self.axis, + transform=tr, + axis_direction=self._axis_direction, + ) + self.label.set_figure(self.axes.get_figure(root=False)) + labelpad = kwargs.get("labelpad", 5) + self.label.set_pad(labelpad) + + def _update_label(self, renderer): + if not self.label.get_visible(): + return + + if self._ticklabel_add_angle != self._axislabel_add_angle: + if ((self.major_ticks.get_visible() + and not self.major_ticks.get_tick_out()) + or (self.minor_ticks.get_visible() + and not self.major_ticks.get_tick_out())): + axislabel_pad = self.major_ticks._ticksize + else: + axislabel_pad = 0 + else: + axislabel_pad = max(self.major_ticklabels._axislabel_pad, + self.minor_ticklabels._axislabel_pad) + + self.label._external_pad = axislabel_pad + + xy, angle_tangent = \ + self._axis_artist_helper.get_axislabel_pos_angle(self.axes) + if xy is None: + return + + angle_label = angle_tangent - 90 + + x, y = xy + self.label._ref_angle = angle_label + self._axislabel_add_angle + self.label.set(x=x, y=y) + + def _draw_label(self, renderer): + self._update_label(renderer) + self.label.draw(renderer) + + def set_label(self, s): + # docstring inherited + self.label.set_text(s) + + def get_tightbbox(self, renderer=None): + if not self.get_visible(): + return + self._axis_artist_helper.update_lim(self.axes) + self._update_ticks(renderer) + self._update_label(renderer) + + self.line.set_path(self._axis_artist_helper.get_line(self.axes)) + if self.get_axisline_style() is not None: + self.line.set_line_mutation_scale(self.major_ticklabels.get_size()) + + bb = [ + *self.major_ticklabels.get_window_extents(renderer), + *self.minor_ticklabels.get_window_extents(renderer), + self.label.get_window_extent(renderer), + self.offsetText.get_window_extent(renderer), + self.line.get_window_extent(renderer), + ] + bb = [b for b in bb if b and (b.width != 0 or b.height != 0)] + if bb: + _bbox = Bbox.union(bb) + return _bbox + else: + return None + + @martist.allow_rasterization + def draw(self, renderer): + # docstring inherited + if not self.get_visible(): + return + renderer.open_group(__name__, gid=self.get_gid()) + self._axis_artist_helper.update_lim(self.axes) + self._draw_ticks(renderer) + self._draw_line(renderer) + self._draw_label(renderer) + renderer.close_group(__name__) + + def toggle(self, all=None, ticks=None, ticklabels=None, label=None): + """ + Toggle visibility of ticks, ticklabels, and (axis) label. + To turn all off, :: + + axis.toggle(all=False) + + To turn all off but ticks on :: + + axis.toggle(all=False, ticks=True) + + To turn all on but (axis) label off :: + + axis.toggle(all=True, label=False) + + """ + if all: + _ticks, _ticklabels, _label = True, True, True + elif all is not None: + _ticks, _ticklabels, _label = False, False, False + else: + _ticks, _ticklabels, _label = None, None, None + + if ticks is not None: + _ticks = ticks + if ticklabels is not None: + _ticklabels = ticklabels + if label is not None: + _label = label + + if _ticks is not None: + self.major_ticks.set_visible(_ticks) + self.minor_ticks.set_visible(_ticks) + if _ticklabels is not None: + self.major_ticklabels.set_visible(_ticklabels) + self.minor_ticklabels.set_visible(_ticklabels) + if _label is not None: + self.label.set_visible(_label) diff --git a/lib/python3.12/site-packages/mpl_toolkits/axisartist/axisline_style.py b/lib/python3.12/site-packages/mpl_toolkits/axisartist/axisline_style.py new file mode 100644 index 0000000000000000000000000000000000000000..7f25b98082ef52b9f162542a3eafaec3b9750fd0 --- /dev/null +++ b/lib/python3.12/site-packages/mpl_toolkits/axisartist/axisline_style.py @@ -0,0 +1,193 @@ +""" +Provides classes to style the axis lines. +""" +import math + +import numpy as np + +import matplotlib as mpl +from matplotlib.patches import _Style, FancyArrowPatch +from matplotlib.path import Path +from matplotlib.transforms import IdentityTransform + + +class _FancyAxislineStyle: + class SimpleArrow(FancyArrowPatch): + """The artist class that will be returned for SimpleArrow style.""" + _ARROW_STYLE = "->" + + def __init__(self, axis_artist, line_path, transform, + line_mutation_scale): + self._axis_artist = axis_artist + self._line_transform = transform + self._line_path = line_path + self._line_mutation_scale = line_mutation_scale + + FancyArrowPatch.__init__(self, + path=self._line_path, + arrowstyle=self._ARROW_STYLE, + patchA=None, + patchB=None, + shrinkA=0., + shrinkB=0., + mutation_scale=line_mutation_scale, + mutation_aspect=None, + transform=IdentityTransform(), + ) + + def set_line_mutation_scale(self, scale): + self.set_mutation_scale(scale*self._line_mutation_scale) + + def _extend_path(self, path, mutation_size=10): + """ + Extend the path to make a room for drawing arrow. + """ + (x0, y0), (x1, y1) = path.vertices[-2:] + theta = math.atan2(y1 - y0, x1 - x0) + x2 = x1 + math.cos(theta) * mutation_size + y2 = y1 + math.sin(theta) * mutation_size + if path.codes is None: + return Path(np.concatenate([path.vertices, [[x2, y2]]])) + else: + return Path(np.concatenate([path.vertices, [[x2, y2]]]), + np.concatenate([path.codes, [Path.LINETO]])) + + def set_path(self, path): + self._line_path = path + + def draw(self, renderer): + """ + Draw the axis line. + 1) Transform the path to the display coordinate. + 2) Extend the path to make a room for arrow. + 3) Update the path of the FancyArrowPatch. + 4) Draw. + """ + path_in_disp = self._line_transform.transform_path(self._line_path) + mutation_size = self.get_mutation_scale() # line_mutation_scale() + extended_path = self._extend_path(path_in_disp, + mutation_size=mutation_size) + self._path_original = extended_path + FancyArrowPatch.draw(self, renderer) + + def get_window_extent(self, renderer=None): + + path_in_disp = self._line_transform.transform_path(self._line_path) + mutation_size = self.get_mutation_scale() # line_mutation_scale() + extended_path = self._extend_path(path_in_disp, + mutation_size=mutation_size) + self._path_original = extended_path + return FancyArrowPatch.get_window_extent(self, renderer) + + class FilledArrow(SimpleArrow): + """The artist class that will be returned for FilledArrow style.""" + _ARROW_STYLE = "-|>" + + def __init__(self, axis_artist, line_path, transform, + line_mutation_scale, facecolor): + super().__init__(axis_artist, line_path, transform, + line_mutation_scale) + self.set_facecolor(facecolor) + + +class AxislineStyle(_Style): + """ + A container class which defines style classes for AxisArtists. + + An instance of any axisline style class is a callable object, + whose call signature is :: + + __call__(self, axis_artist, path, transform) + + When called, this should return an `.Artist` with the following methods:: + + def set_path(self, path): + # set the path for axisline. + + def set_line_mutation_scale(self, scale): + # set the scale + + def draw(self, renderer): + # draw + """ + + _style_list = {} + + class _Base: + # The derived classes are required to be able to be initialized + # w/o arguments, i.e., all its argument (except self) must have + # the default values. + + def __init__(self): + """ + initialization. + """ + super().__init__() + + def __call__(self, axis_artist, transform): + """ + Given the AxisArtist instance, and transform for the path (set_path + method), return the Matplotlib artist for drawing the axis line. + """ + return self.new_line(axis_artist, transform) + + class SimpleArrow(_Base): + """ + A simple arrow. + """ + + ArrowAxisClass = _FancyAxislineStyle.SimpleArrow + + def __init__(self, size=1): + """ + Parameters + ---------- + size : float + Size of the arrow as a fraction of the ticklabel size. + """ + + self.size = size + super().__init__() + + def new_line(self, axis_artist, transform): + + linepath = Path([(0, 0), (0, 1)]) + axisline = self.ArrowAxisClass(axis_artist, linepath, transform, + line_mutation_scale=self.size) + return axisline + + _style_list["->"] = SimpleArrow + + class FilledArrow(SimpleArrow): + """ + An arrow with a filled head. + """ + + ArrowAxisClass = _FancyAxislineStyle.FilledArrow + + def __init__(self, size=1, facecolor=None): + """ + Parameters + ---------- + size : float + Size of the arrow as a fraction of the ticklabel size. + facecolor : :mpltype:`color`, default: :rc:`axes.edgecolor` + Fill color. + + .. versionadded:: 3.7 + """ + + if facecolor is None: + facecolor = mpl.rcParams['axes.edgecolor'] + self.size = size + self._facecolor = facecolor + super().__init__(size=size) + + def new_line(self, axis_artist, transform): + linepath = Path([(0, 0), (0, 1)]) + axisline = self.ArrowAxisClass(axis_artist, linepath, transform, + line_mutation_scale=self.size, + facecolor=self._facecolor) + return axisline + + _style_list["-|>"] = FilledArrow diff --git a/lib/python3.12/site-packages/mpl_toolkits/axisartist/axislines.py b/lib/python3.12/site-packages/mpl_toolkits/axisartist/axislines.py new file mode 100644 index 0000000000000000000000000000000000000000..8d06cb236269d00ed20a27297b59fa816338e79a --- /dev/null +++ b/lib/python3.12/site-packages/mpl_toolkits/axisartist/axislines.py @@ -0,0 +1,479 @@ +""" +Axislines includes modified implementation of the Axes class. The +biggest difference is that the artists responsible for drawing the axis spine, +ticks, ticklabels and axis labels are separated out from Matplotlib's Axis +class. Originally, this change was motivated to support curvilinear +grid. Here are a few reasons that I came up with a new axes class: + +* "top" and "bottom" x-axis (or "left" and "right" y-axis) can have + different ticks (tick locations and labels). This is not possible + with the current Matplotlib, although some twin axes trick can help. + +* Curvilinear grid. + +* angled ticks. + +In the new axes class, xaxis and yaxis is set to not visible by +default, and new set of artist (AxisArtist) are defined to draw axis +line, ticks, ticklabels and axis label. Axes.axis attribute serves as +a dictionary of these artists, i.e., ax.axis["left"] is a AxisArtist +instance responsible to draw left y-axis. The default Axes.axis contains +"bottom", "left", "top" and "right". + +AxisArtist can be considered as a container artist and has the following +children artists which will draw ticks, labels, etc. + +* line +* major_ticks, major_ticklabels +* minor_ticks, minor_ticklabels +* offsetText +* label + +Note that these are separate artists from `matplotlib.axis.Axis`, thus most +tick-related functions in Matplotlib won't work. For example, color and +markerwidth of the ``ax.axis["bottom"].major_ticks`` will follow those of +Axes.xaxis unless explicitly specified. + +In addition to AxisArtist, the Axes will have *gridlines* attribute, +which obviously draws grid lines. The gridlines needs to be separated +from the axis as some gridlines can never pass any axis. +""" + +import numpy as np + +import matplotlib as mpl +from matplotlib import _api +import matplotlib.axes as maxes +from matplotlib.path import Path +from mpl_toolkits.axes_grid1 import mpl_axes +from .axisline_style import AxislineStyle # noqa +from .axis_artist import AxisArtist, GridlinesCollection + + +class _AxisArtistHelperBase: + """ + Base class for axis helper. + + Subclasses should define the methods listed below. The *axes* + argument will be the ``.axes`` attribute of the caller artist. :: + + # Construct the spine. + + def get_line_transform(self, axes): + return transform + + def get_line(self, axes): + return path + + # Construct the label. + + def get_axislabel_transform(self, axes): + return transform + + def get_axislabel_pos_angle(self, axes): + return (x, y), angle + + # Construct the ticks. + + def get_tick_transform(self, axes): + return transform + + def get_tick_iterators(self, axes): + # A pair of iterables (one for major ticks, one for minor ticks) + # that yield (tick_position, tick_angle, tick_label). + return iter_major, iter_minor + """ + + def __init__(self, nth_coord): + self.nth_coord = nth_coord + + def update_lim(self, axes): + pass + + def get_nth_coord(self): + return self.nth_coord + + def _to_xy(self, values, const): + """ + Create a (*values.shape, 2)-shape array representing (x, y) pairs. + + The other coordinate is filled with the constant *const*. + + Example:: + + >>> self.nth_coord = 0 + >>> self._to_xy([1, 2, 3], const=0) + array([[1, 0], + [2, 0], + [3, 0]]) + """ + if self.nth_coord == 0: + return np.stack(np.broadcast_arrays(values, const), axis=-1) + elif self.nth_coord == 1: + return np.stack(np.broadcast_arrays(const, values), axis=-1) + else: + raise ValueError("Unexpected nth_coord") + + +class _FixedAxisArtistHelperBase(_AxisArtistHelperBase): + """Helper class for a fixed (in the axes coordinate) axis.""" + + @_api.delete_parameter("3.9", "nth_coord") + def __init__(self, loc, nth_coord=None): + """``nth_coord = 0``: x-axis; ``nth_coord = 1``: y-axis.""" + super().__init__(_api.check_getitem( + {"bottom": 0, "top": 0, "left": 1, "right": 1}, loc=loc)) + self._loc = loc + self._pos = {"bottom": 0, "top": 1, "left": 0, "right": 1}[loc] + # axis line in transAxes + self._path = Path(self._to_xy((0, 1), const=self._pos)) + + # LINE + + def get_line(self, axes): + return self._path + + def get_line_transform(self, axes): + return axes.transAxes + + # LABEL + + def get_axislabel_transform(self, axes): + return axes.transAxes + + def get_axislabel_pos_angle(self, axes): + """ + Return the label reference position in transAxes. + + get_label_transform() returns a transform of (transAxes+offset) + """ + return dict(left=((0., 0.5), 90), # (position, angle_tangent) + right=((1., 0.5), 90), + bottom=((0.5, 0.), 0), + top=((0.5, 1.), 0))[self._loc] + + # TICK + + def get_tick_transform(self, axes): + return [axes.get_xaxis_transform(), axes.get_yaxis_transform()][self.nth_coord] + + +class _FloatingAxisArtistHelperBase(_AxisArtistHelperBase): + def __init__(self, nth_coord, value): + self._value = value + super().__init__(nth_coord) + + def get_line(self, axes): + raise RuntimeError("get_line method should be defined by the derived class") + + +class FixedAxisArtistHelperRectilinear(_FixedAxisArtistHelperBase): + + @_api.delete_parameter("3.9", "nth_coord") + def __init__(self, axes, loc, nth_coord=None): + """ + nth_coord = along which coordinate value varies + in 2D, nth_coord = 0 -> x axis, nth_coord = 1 -> y axis + """ + super().__init__(loc) + self.axis = [axes.xaxis, axes.yaxis][self.nth_coord] + + # TICK + + def get_tick_iterators(self, axes): + """tick_loc, tick_angle, tick_label""" + angle_normal, angle_tangent = {0: (90, 0), 1: (0, 90)}[self.nth_coord] + + major = self.axis.major + major_locs = major.locator() + major_labels = major.formatter.format_ticks(major_locs) + + minor = self.axis.minor + minor_locs = minor.locator() + minor_labels = minor.formatter.format_ticks(minor_locs) + + tick_to_axes = self.get_tick_transform(axes) - axes.transAxes + + def _f(locs, labels): + for loc, label in zip(locs, labels): + c = self._to_xy(loc, const=self._pos) + # check if the tick point is inside axes + c2 = tick_to_axes.transform(c) + if mpl.transforms._interval_contains_close((0, 1), c2[self.nth_coord]): + yield c, angle_normal, angle_tangent, label + + return _f(major_locs, major_labels), _f(minor_locs, minor_labels) + + +class FloatingAxisArtistHelperRectilinear(_FloatingAxisArtistHelperBase): + + def __init__(self, axes, nth_coord, + passingthrough_point, axis_direction="bottom"): + super().__init__(nth_coord, passingthrough_point) + self._axis_direction = axis_direction + self.axis = [axes.xaxis, axes.yaxis][self.nth_coord] + + def get_line(self, axes): + fixed_coord = 1 - self.nth_coord + data_to_axes = axes.transData - axes.transAxes + p = data_to_axes.transform([self._value, self._value]) + return Path(self._to_xy((0, 1), const=p[fixed_coord])) + + def get_line_transform(self, axes): + return axes.transAxes + + def get_axislabel_transform(self, axes): + return axes.transAxes + + def get_axislabel_pos_angle(self, axes): + """ + Return the label reference position in transAxes. + + get_label_transform() returns a transform of (transAxes+offset) + """ + angle = [0, 90][self.nth_coord] + fixed_coord = 1 - self.nth_coord + data_to_axes = axes.transData - axes.transAxes + p = data_to_axes.transform([self._value, self._value]) + verts = self._to_xy(0.5, const=p[fixed_coord]) + return (verts, angle) if 0 <= verts[fixed_coord] <= 1 else (None, None) + + def get_tick_transform(self, axes): + return axes.transData + + def get_tick_iterators(self, axes): + """tick_loc, tick_angle, tick_label""" + angle_normal, angle_tangent = {0: (90, 0), 1: (0, 90)}[self.nth_coord] + + major = self.axis.major + major_locs = major.locator() + major_labels = major.formatter.format_ticks(major_locs) + + minor = self.axis.minor + minor_locs = minor.locator() + minor_labels = minor.formatter.format_ticks(minor_locs) + + data_to_axes = axes.transData - axes.transAxes + + def _f(locs, labels): + for loc, label in zip(locs, labels): + c = self._to_xy(loc, const=self._value) + c1, c2 = data_to_axes.transform(c) + if 0 <= c1 <= 1 and 0 <= c2 <= 1: + yield c, angle_normal, angle_tangent, label + + return _f(major_locs, major_labels), _f(minor_locs, minor_labels) + + +class AxisArtistHelper: # Backcompat. + Fixed = _FixedAxisArtistHelperBase + Floating = _FloatingAxisArtistHelperBase + + +class AxisArtistHelperRectlinear: # Backcompat. + Fixed = FixedAxisArtistHelperRectilinear + Floating = FloatingAxisArtistHelperRectilinear + + +class GridHelperBase: + + def __init__(self): + self._old_limits = None + super().__init__() + + def update_lim(self, axes): + x1, x2 = axes.get_xlim() + y1, y2 = axes.get_ylim() + if self._old_limits != (x1, x2, y1, y2): + self._update_grid(x1, y1, x2, y2) + self._old_limits = (x1, x2, y1, y2) + + def _update_grid(self, x1, y1, x2, y2): + """Cache relevant computations when the axes limits have changed.""" + + def get_gridlines(self, which, axis): + """ + Return list of grid lines as a list of paths (list of points). + + Parameters + ---------- + which : {"both", "major", "minor"} + axis : {"both", "x", "y"} + """ + return [] + + +class GridHelperRectlinear(GridHelperBase): + + def __init__(self, axes): + super().__init__() + self.axes = axes + + @_api.delete_parameter( + "3.9", "nth_coord", addendum="'nth_coord' is now inferred from 'loc'.") + def new_fixed_axis( + self, loc, nth_coord=None, axis_direction=None, offset=None, axes=None): + if axes is None: + _api.warn_external( + "'new_fixed_axis' explicitly requires the axes keyword.") + axes = self.axes + if axis_direction is None: + axis_direction = loc + return AxisArtist(axes, FixedAxisArtistHelperRectilinear(axes, loc), + offset=offset, axis_direction=axis_direction) + + def new_floating_axis(self, nth_coord, value, axis_direction="bottom", axes=None): + if axes is None: + _api.warn_external( + "'new_floating_axis' explicitly requires the axes keyword.") + axes = self.axes + helper = FloatingAxisArtistHelperRectilinear( + axes, nth_coord, value, axis_direction) + axisline = AxisArtist(axes, helper, axis_direction=axis_direction) + axisline.line.set_clip_on(True) + axisline.line.set_clip_box(axisline.axes.bbox) + return axisline + + def get_gridlines(self, which="major", axis="both"): + """ + Return list of gridline coordinates in data coordinates. + + Parameters + ---------- + which : {"both", "major", "minor"} + axis : {"both", "x", "y"} + """ + _api.check_in_list(["both", "major", "minor"], which=which) + _api.check_in_list(["both", "x", "y"], axis=axis) + gridlines = [] + + if axis in ("both", "x"): + locs = [] + y1, y2 = self.axes.get_ylim() + if which in ("both", "major"): + locs.extend(self.axes.xaxis.major.locator()) + if which in ("both", "minor"): + locs.extend(self.axes.xaxis.minor.locator()) + gridlines.extend([[x, x], [y1, y2]] for x in locs) + + if axis in ("both", "y"): + x1, x2 = self.axes.get_xlim() + locs = [] + if self.axes.yaxis._major_tick_kw["gridOn"]: + locs.extend(self.axes.yaxis.major.locator()) + if self.axes.yaxis._minor_tick_kw["gridOn"]: + locs.extend(self.axes.yaxis.minor.locator()) + gridlines.extend([[x1, x2], [y, y]] for y in locs) + + return gridlines + + +class Axes(maxes.Axes): + + def __init__(self, *args, grid_helper=None, **kwargs): + self._axisline_on = True + self._grid_helper = grid_helper if grid_helper else GridHelperRectlinear(self) + super().__init__(*args, **kwargs) + self.toggle_axisline(True) + + def toggle_axisline(self, b=None): + if b is None: + b = not self._axisline_on + if b: + self._axisline_on = True + self.spines[:].set_visible(False) + self.xaxis.set_visible(False) + self.yaxis.set_visible(False) + else: + self._axisline_on = False + self.spines[:].set_visible(True) + self.xaxis.set_visible(True) + self.yaxis.set_visible(True) + + @property + def axis(self): + return self._axislines + + def clear(self): + # docstring inherited + + # Init gridlines before clear() as clear() calls grid(). + self.gridlines = gridlines = GridlinesCollection( + [], + colors=mpl.rcParams['grid.color'], + linestyles=mpl.rcParams['grid.linestyle'], + linewidths=mpl.rcParams['grid.linewidth']) + self._set_artist_props(gridlines) + gridlines.set_grid_helper(self.get_grid_helper()) + + super().clear() + + # clip_path is set after Axes.clear(): that's when a patch is created. + gridlines.set_clip_path(self.axes.patch) + + # Init axis artists. + self._axislines = mpl_axes.Axes.AxisDict(self) + new_fixed_axis = self.get_grid_helper().new_fixed_axis + self._axislines.update({ + loc: new_fixed_axis(loc=loc, axes=self, axis_direction=loc) + for loc in ["bottom", "top", "left", "right"]}) + for axisline in [self._axislines["top"], self._axislines["right"]]: + axisline.label.set_visible(False) + axisline.major_ticklabels.set_visible(False) + axisline.minor_ticklabels.set_visible(False) + + def get_grid_helper(self): + return self._grid_helper + + def grid(self, visible=None, which='major', axis="both", **kwargs): + """ + Toggle the gridlines, and optionally set the properties of the lines. + """ + # There are some discrepancies in the behavior of grid() between + # axes_grid and Matplotlib, because axes_grid explicitly sets the + # visibility of the gridlines. + super().grid(visible, which=which, axis=axis, **kwargs) + if not self._axisline_on: + return + if visible is None: + visible = (self.axes.xaxis._minor_tick_kw["gridOn"] + or self.axes.xaxis._major_tick_kw["gridOn"] + or self.axes.yaxis._minor_tick_kw["gridOn"] + or self.axes.yaxis._major_tick_kw["gridOn"]) + self.gridlines.set(which=which, axis=axis, visible=visible) + self.gridlines.set(**kwargs) + + def get_children(self): + if self._axisline_on: + children = [*self._axislines.values(), self.gridlines] + else: + children = [] + children.extend(super().get_children()) + return children + + def new_fixed_axis(self, loc, offset=None): + return self.get_grid_helper().new_fixed_axis(loc, offset=offset, axes=self) + + def new_floating_axis(self, nth_coord, value, axis_direction="bottom"): + return self.get_grid_helper().new_floating_axis( + nth_coord, value, axis_direction=axis_direction, axes=self) + + +class AxesZero(Axes): + + def clear(self): + super().clear() + new_floating_axis = self.get_grid_helper().new_floating_axis + self._axislines.update( + xzero=new_floating_axis( + nth_coord=0, value=0., axis_direction="bottom", axes=self), + yzero=new_floating_axis( + nth_coord=1, value=0., axis_direction="left", axes=self), + ) + for k in ["xzero", "yzero"]: + self._axislines[k].line.set_clip_path(self.patch) + self._axislines[k].set_visible(False) + + +Subplot = Axes +SubplotZero = AxesZero diff --git a/lib/python3.12/site-packages/mpl_toolkits/axisartist/floating_axes.py b/lib/python3.12/site-packages/mpl_toolkits/axisartist/floating_axes.py new file mode 100644 index 0000000000000000000000000000000000000000..74e4c941879babca194cec7d60fe949e648cb4cf --- /dev/null +++ b/lib/python3.12/site-packages/mpl_toolkits/axisartist/floating_axes.py @@ -0,0 +1,275 @@ +""" +An experimental support for curvilinear grid. +""" + +# TODO : +# see if tick_iterator method can be simplified by reusing the parent method. + +import functools + +import numpy as np + +import matplotlib as mpl +from matplotlib import _api, cbook +import matplotlib.patches as mpatches +from matplotlib.path import Path + +from mpl_toolkits.axes_grid1.parasite_axes import host_axes_class_factory + +from . import axislines, grid_helper_curvelinear +from .axis_artist import AxisArtist +from .grid_finder import ExtremeFinderSimple + + +class FloatingAxisArtistHelper( + grid_helper_curvelinear.FloatingAxisArtistHelper): + pass + + +class FixedAxisArtistHelper(grid_helper_curvelinear.FloatingAxisArtistHelper): + + def __init__(self, grid_helper, side, nth_coord_ticks=None): + """ + nth_coord = along which coordinate value varies. + nth_coord = 0 -> x axis, nth_coord = 1 -> y axis + """ + lon1, lon2, lat1, lat2 = grid_helper.grid_finder.extreme_finder(*[None] * 5) + value, nth_coord = _api.check_getitem( + dict(left=(lon1, 0), right=(lon2, 0), bottom=(lat1, 1), top=(lat2, 1)), + side=side) + super().__init__(grid_helper, nth_coord, value, axis_direction=side) + if nth_coord_ticks is None: + nth_coord_ticks = nth_coord + self.nth_coord_ticks = nth_coord_ticks + + self.value = value + self.grid_helper = grid_helper + self._side = side + + def update_lim(self, axes): + self.grid_helper.update_lim(axes) + self._grid_info = self.grid_helper._grid_info + + def get_tick_iterators(self, axes): + """tick_loc, tick_angle, tick_label, (optionally) tick_label""" + + grid_finder = self.grid_helper.grid_finder + + lat_levs, lat_n, lat_factor = self._grid_info["lat_info"] + yy0 = lat_levs / lat_factor + + lon_levs, lon_n, lon_factor = self._grid_info["lon_info"] + xx0 = lon_levs / lon_factor + + extremes = self.grid_helper.grid_finder.extreme_finder(*[None] * 5) + xmin, xmax = sorted(extremes[:2]) + ymin, ymax = sorted(extremes[2:]) + + def trf_xy(x, y): + trf = grid_finder.get_transform() + axes.transData + return trf.transform(np.column_stack(np.broadcast_arrays(x, y))).T + + if self.nth_coord == 0: + mask = (ymin <= yy0) & (yy0 <= ymax) + (xx1, yy1), (dxx1, dyy1), (dxx2, dyy2) = \ + grid_helper_curvelinear._value_and_jacobian( + trf_xy, self.value, yy0[mask], (xmin, xmax), (ymin, ymax)) + labels = self._grid_info["lat_labels"] + + elif self.nth_coord == 1: + mask = (xmin <= xx0) & (xx0 <= xmax) + (xx1, yy1), (dxx2, dyy2), (dxx1, dyy1) = \ + grid_helper_curvelinear._value_and_jacobian( + trf_xy, xx0[mask], self.value, (xmin, xmax), (ymin, ymax)) + labels = self._grid_info["lon_labels"] + + labels = [l for l, m in zip(labels, mask) if m] + + angle_normal = np.arctan2(dyy1, dxx1) + angle_tangent = np.arctan2(dyy2, dxx2) + mm = (dyy1 == 0) & (dxx1 == 0) # points with degenerate normal + angle_normal[mm] = angle_tangent[mm] + np.pi / 2 + + tick_to_axes = self.get_tick_transform(axes) - axes.transAxes + in_01 = functools.partial( + mpl.transforms._interval_contains_close, (0, 1)) + + def f1(): + for x, y, normal, tangent, lab \ + in zip(xx1, yy1, angle_normal, angle_tangent, labels): + c2 = tick_to_axes.transform((x, y)) + if in_01(c2[0]) and in_01(c2[1]): + yield [x, y], *np.rad2deg([normal, tangent]), lab + + return f1(), iter([]) + + def get_line(self, axes): + self.update_lim(axes) + k, v = dict(left=("lon_lines0", 0), + right=("lon_lines0", 1), + bottom=("lat_lines0", 0), + top=("lat_lines0", 1))[self._side] + xx, yy = self._grid_info[k][v] + return Path(np.column_stack([xx, yy])) + + +class ExtremeFinderFixed(ExtremeFinderSimple): + # docstring inherited + + def __init__(self, extremes): + """ + This subclass always returns the same bounding box. + + Parameters + ---------- + extremes : (float, float, float, float) + The bounding box that this helper always returns. + """ + self._extremes = extremes + + def __call__(self, transform_xy, x1, y1, x2, y2): + # docstring inherited + return self._extremes + + +class GridHelperCurveLinear(grid_helper_curvelinear.GridHelperCurveLinear): + + def __init__(self, aux_trans, extremes, + grid_locator1=None, + grid_locator2=None, + tick_formatter1=None, + tick_formatter2=None): + # docstring inherited + super().__init__(aux_trans, + extreme_finder=ExtremeFinderFixed(extremes), + grid_locator1=grid_locator1, + grid_locator2=grid_locator2, + tick_formatter1=tick_formatter1, + tick_formatter2=tick_formatter2) + + def new_fixed_axis( + self, loc, nth_coord=None, axis_direction=None, offset=None, axes=None): + if axes is None: + axes = self.axes + if axis_direction is None: + axis_direction = loc + # This is not the same as the FixedAxisArtistHelper class used by + # grid_helper_curvelinear.GridHelperCurveLinear.new_fixed_axis! + helper = FixedAxisArtistHelper( + self, loc, nth_coord_ticks=nth_coord) + axisline = AxisArtist(axes, helper, axis_direction=axis_direction) + # Perhaps should be moved to the base class? + axisline.line.set_clip_on(True) + axisline.line.set_clip_box(axisline.axes.bbox) + return axisline + + # new_floating_axis will inherit the grid_helper's extremes. + + # def new_floating_axis(self, nth_coord, value, axes=None, axis_direction="bottom"): + # axis = super(GridHelperCurveLinear, + # self).new_floating_axis(nth_coord, + # value, axes=axes, + # axis_direction=axis_direction) + # # set extreme values of the axis helper + # if nth_coord == 1: + # axis.get_helper().set_extremes(*self._extremes[:2]) + # elif nth_coord == 0: + # axis.get_helper().set_extremes(*self._extremes[2:]) + # return axis + + def _update_grid(self, x1, y1, x2, y2): + if self._grid_info is None: + self._grid_info = dict() + + grid_info = self._grid_info + + grid_finder = self.grid_finder + extremes = grid_finder.extreme_finder(grid_finder.inv_transform_xy, + x1, y1, x2, y2) + + lon_min, lon_max = sorted(extremes[:2]) + lat_min, lat_max = sorted(extremes[2:]) + grid_info["extremes"] = lon_min, lon_max, lat_min, lat_max # extremes + + lon_levs, lon_n, lon_factor = \ + grid_finder.grid_locator1(lon_min, lon_max) + lon_levs = np.asarray(lon_levs) + lat_levs, lat_n, lat_factor = \ + grid_finder.grid_locator2(lat_min, lat_max) + lat_levs = np.asarray(lat_levs) + + grid_info["lon_info"] = lon_levs, lon_n, lon_factor + grid_info["lat_info"] = lat_levs, lat_n, lat_factor + + grid_info["lon_labels"] = grid_finder._format_ticks( + 1, "bottom", lon_factor, lon_levs) + grid_info["lat_labels"] = grid_finder._format_ticks( + 2, "bottom", lat_factor, lat_levs) + + lon_values = lon_levs[:lon_n] / lon_factor + lat_values = lat_levs[:lat_n] / lat_factor + + lon_lines, lat_lines = grid_finder._get_raw_grid_lines( + lon_values[(lon_min < lon_values) & (lon_values < lon_max)], + lat_values[(lat_min < lat_values) & (lat_values < lat_max)], + lon_min, lon_max, lat_min, lat_max) + + grid_info["lon_lines"] = lon_lines + grid_info["lat_lines"] = lat_lines + + lon_lines, lat_lines = grid_finder._get_raw_grid_lines( + # lon_min, lon_max, lat_min, lat_max) + extremes[:2], extremes[2:], *extremes) + + grid_info["lon_lines0"] = lon_lines + grid_info["lat_lines0"] = lat_lines + + def get_gridlines(self, which="major", axis="both"): + grid_lines = [] + if axis in ["both", "x"]: + grid_lines.extend(self._grid_info["lon_lines"]) + if axis in ["both", "y"]: + grid_lines.extend(self._grid_info["lat_lines"]) + return grid_lines + + +class FloatingAxesBase: + + def __init__(self, *args, grid_helper, **kwargs): + _api.check_isinstance(GridHelperCurveLinear, grid_helper=grid_helper) + super().__init__(*args, grid_helper=grid_helper, **kwargs) + self.set_aspect(1.) + + def _gen_axes_patch(self): + # docstring inherited + x0, x1, y0, y1 = self.get_grid_helper().grid_finder.extreme_finder(*[None] * 5) + patch = mpatches.Polygon([(x0, y0), (x1, y0), (x1, y1), (x0, y1)]) + patch.get_path()._interpolation_steps = 100 + return patch + + def clear(self): + super().clear() + self.patch.set_transform( + self.get_grid_helper().grid_finder.get_transform() + + self.transData) + # The original patch is not in the draw tree; it is only used for + # clipping purposes. + orig_patch = super()._gen_axes_patch() + orig_patch.set_figure(self.get_figure(root=False)) + orig_patch.set_transform(self.transAxes) + self.patch.set_clip_path(orig_patch) + self.gridlines.set_clip_path(orig_patch) + self.adjust_axes_lim() + + def adjust_axes_lim(self): + bbox = self.patch.get_path().get_extents( + # First transform to pixel coords, then to parent data coords. + self.patch.get_transform() - self.transData) + bbox = bbox.expanded(1.02, 1.02) + self.set_xlim(bbox.xmin, bbox.xmax) + self.set_ylim(bbox.ymin, bbox.ymax) + + +floatingaxes_class_factory = cbook._make_class_factory(FloatingAxesBase, "Floating{}") +FloatingAxes = floatingaxes_class_factory(host_axes_class_factory(axislines.Axes)) +FloatingSubplot = FloatingAxes diff --git a/lib/python3.12/site-packages/mpl_toolkits/axisartist/grid_finder.py b/lib/python3.12/site-packages/mpl_toolkits/axisartist/grid_finder.py new file mode 100644 index 0000000000000000000000000000000000000000..ff67aa6e872044bb74e8f2e3882a4d3719cbd55d --- /dev/null +++ b/lib/python3.12/site-packages/mpl_toolkits/axisartist/grid_finder.py @@ -0,0 +1,326 @@ +import numpy as np + +from matplotlib import ticker as mticker, _api +from matplotlib.transforms import Bbox, Transform + + +def _find_line_box_crossings(xys, bbox): + """ + Find the points where a polyline crosses a bbox, and the crossing angles. + + Parameters + ---------- + xys : (N, 2) array + The polyline coordinates. + bbox : `.Bbox` + The bounding box. + + Returns + ------- + list of ((float, float), float) + Four separate lists of crossings, for the left, right, bottom, and top + sides of the bbox, respectively. For each list, the entries are the + ``((x, y), ccw_angle_in_degrees)`` of the crossing, where an angle of 0 + means that the polyline is moving to the right at the crossing point. + + The entries are computed by linearly interpolating at each crossing + between the nearest points on either side of the bbox edges. + """ + crossings = [] + dxys = xys[1:] - xys[:-1] + for sl in [slice(None), slice(None, None, -1)]: + us, vs = xys.T[sl] # "this" coord, "other" coord + dus, dvs = dxys.T[sl] + umin, vmin = bbox.min[sl] + umax, vmax = bbox.max[sl] + for u0, inside in [(umin, us > umin), (umax, us < umax)]: + cross = [] + idxs, = (inside[:-1] ^ inside[1:]).nonzero() + for idx in idxs: + v = vs[idx] + (u0 - us[idx]) * dvs[idx] / dus[idx] + if not vmin <= v <= vmax: + continue + crossing = (u0, v)[sl] + theta = np.degrees(np.arctan2(*dxys[idx][::-1])) + cross.append((crossing, theta)) + crossings.append(cross) + return crossings + + +class ExtremeFinderSimple: + """ + A helper class to figure out the range of grid lines that need to be drawn. + """ + + def __init__(self, nx, ny): + """ + Parameters + ---------- + nx, ny : int + The number of samples in each direction. + """ + self.nx = nx + self.ny = ny + + def __call__(self, transform_xy, x1, y1, x2, y2): + """ + Compute an approximation of the bounding box obtained by applying + *transform_xy* to the box delimited by ``(x1, y1, x2, y2)``. + + The intended use is to have ``(x1, y1, x2, y2)`` in axes coordinates, + and have *transform_xy* be the transform from axes coordinates to data + coordinates; this method then returns the range of data coordinates + that span the actual axes. + + The computation is done by sampling ``nx * ny`` equispaced points in + the ``(x1, y1, x2, y2)`` box and finding the resulting points with + extremal coordinates; then adding some padding to take into account the + finite sampling. + + As each sampling step covers a relative range of *1/nx* or *1/ny*, + the padding is computed by expanding the span covered by the extremal + coordinates by these fractions. + """ + x, y = np.meshgrid( + np.linspace(x1, x2, self.nx), np.linspace(y1, y2, self.ny)) + xt, yt = transform_xy(np.ravel(x), np.ravel(y)) + return self._add_pad(xt.min(), xt.max(), yt.min(), yt.max()) + + def _add_pad(self, x_min, x_max, y_min, y_max): + """Perform the padding mentioned in `__call__`.""" + dx = (x_max - x_min) / self.nx + dy = (y_max - y_min) / self.ny + return x_min - dx, x_max + dx, y_min - dy, y_max + dy + + +class _User2DTransform(Transform): + """A transform defined by two user-set functions.""" + + input_dims = output_dims = 2 + + def __init__(self, forward, backward): + """ + Parameters + ---------- + forward, backward : callable + The forward and backward transforms, taking ``x`` and ``y`` as + separate arguments and returning ``(tr_x, tr_y)``. + """ + # The normal Matplotlib convention would be to take and return an + # (N, 2) array but axisartist uses the transposed version. + super().__init__() + self._forward = forward + self._backward = backward + + def transform_non_affine(self, values): + # docstring inherited + return np.transpose(self._forward(*np.transpose(values))) + + def inverted(self): + # docstring inherited + return type(self)(self._backward, self._forward) + + +class GridFinder: + """ + Internal helper for `~.grid_helper_curvelinear.GridHelperCurveLinear`, with + the same constructor parameters; should not be directly instantiated. + """ + + def __init__(self, + transform, + extreme_finder=None, + grid_locator1=None, + grid_locator2=None, + tick_formatter1=None, + tick_formatter2=None): + if extreme_finder is None: + extreme_finder = ExtremeFinderSimple(20, 20) + if grid_locator1 is None: + grid_locator1 = MaxNLocator() + if grid_locator2 is None: + grid_locator2 = MaxNLocator() + if tick_formatter1 is None: + tick_formatter1 = FormatterPrettyPrint() + if tick_formatter2 is None: + tick_formatter2 = FormatterPrettyPrint() + self.extreme_finder = extreme_finder + self.grid_locator1 = grid_locator1 + self.grid_locator2 = grid_locator2 + self.tick_formatter1 = tick_formatter1 + self.tick_formatter2 = tick_formatter2 + self.set_transform(transform) + + def _format_ticks(self, idx, direction, factor, levels): + """ + Helper to support both standard formatters (inheriting from + `.mticker.Formatter`) and axisartist-specific ones; should be called instead of + directly calling ``self.tick_formatter1`` and ``self.tick_formatter2``. This + method should be considered as a temporary workaround which will be removed in + the future at the same time as axisartist-specific formatters. + """ + fmt = _api.check_getitem( + {1: self.tick_formatter1, 2: self.tick_formatter2}, idx=idx) + return (fmt.format_ticks(levels) if isinstance(fmt, mticker.Formatter) + else fmt(direction, factor, levels)) + + def get_grid_info(self, x1, y1, x2, y2): + """ + lon_values, lat_values : list of grid values. if integer is given, + rough number of grids in each direction. + """ + + extremes = self.extreme_finder(self.inv_transform_xy, x1, y1, x2, y2) + + # min & max rage of lat (or lon) for each grid line will be drawn. + # i.e., gridline of lon=0 will be drawn from lat_min to lat_max. + + lon_min, lon_max, lat_min, lat_max = extremes + lon_levs, lon_n, lon_factor = self.grid_locator1(lon_min, lon_max) + lon_levs = np.asarray(lon_levs) + lat_levs, lat_n, lat_factor = self.grid_locator2(lat_min, lat_max) + lat_levs = np.asarray(lat_levs) + + lon_values = lon_levs[:lon_n] / lon_factor + lat_values = lat_levs[:lat_n] / lat_factor + + lon_lines, lat_lines = self._get_raw_grid_lines(lon_values, + lat_values, + lon_min, lon_max, + lat_min, lat_max) + + bb = Bbox.from_extents(x1, y1, x2, y2).expanded(1 + 2e-10, 1 + 2e-10) + + grid_info = { + "extremes": extremes, + # "lon", "lat", filled below. + } + + for idx, lon_or_lat, levs, factor, values, lines in [ + (1, "lon", lon_levs, lon_factor, lon_values, lon_lines), + (2, "lat", lat_levs, lat_factor, lat_values, lat_lines), + ]: + grid_info[lon_or_lat] = gi = { + "lines": [[l] for l in lines], + "ticks": {"left": [], "right": [], "bottom": [], "top": []}, + } + for (lx, ly), v, level in zip(lines, values, levs): + all_crossings = _find_line_box_crossings(np.column_stack([lx, ly]), bb) + for side, crossings in zip( + ["left", "right", "bottom", "top"], all_crossings): + for crossing in crossings: + gi["ticks"][side].append({"level": level, "loc": crossing}) + for side in gi["ticks"]: + levs = [tick["level"] for tick in gi["ticks"][side]] + labels = self._format_ticks(idx, side, factor, levs) + for tick, label in zip(gi["ticks"][side], labels): + tick["label"] = label + + return grid_info + + def _get_raw_grid_lines(self, + lon_values, lat_values, + lon_min, lon_max, lat_min, lat_max): + + lons_i = np.linspace(lon_min, lon_max, 100) # for interpolation + lats_i = np.linspace(lat_min, lat_max, 100) + + lon_lines = [self.transform_xy(np.full_like(lats_i, lon), lats_i) + for lon in lon_values] + lat_lines = [self.transform_xy(lons_i, np.full_like(lons_i, lat)) + for lat in lat_values] + + return lon_lines, lat_lines + + def set_transform(self, aux_trans): + if isinstance(aux_trans, Transform): + self._aux_transform = aux_trans + elif len(aux_trans) == 2 and all(map(callable, aux_trans)): + self._aux_transform = _User2DTransform(*aux_trans) + else: + raise TypeError("'aux_trans' must be either a Transform " + "instance or a pair of callables") + + def get_transform(self): + return self._aux_transform + + update_transform = set_transform # backcompat alias. + + def transform_xy(self, x, y): + return self._aux_transform.transform(np.column_stack([x, y])).T + + def inv_transform_xy(self, x, y): + return self._aux_transform.inverted().transform( + np.column_stack([x, y])).T + + def update(self, **kwargs): + for k, v in kwargs.items(): + if k in ["extreme_finder", + "grid_locator1", + "grid_locator2", + "tick_formatter1", + "tick_formatter2"]: + setattr(self, k, v) + else: + raise ValueError(f"Unknown update property {k!r}") + + +class MaxNLocator(mticker.MaxNLocator): + def __init__(self, nbins=10, steps=None, + trim=True, + integer=False, + symmetric=False, + prune=None): + # trim argument has no effect. It has been left for API compatibility + super().__init__(nbins, steps=steps, integer=integer, + symmetric=symmetric, prune=prune) + self.create_dummy_axis() + + def __call__(self, v1, v2): + locs = super().tick_values(v1, v2) + return np.array(locs), len(locs), 1 # 1: factor (see angle_helper) + + +class FixedLocator: + def __init__(self, locs): + self._locs = locs + + def __call__(self, v1, v2): + v1, v2 = sorted([v1, v2]) + locs = np.array([l for l in self._locs if v1 <= l <= v2]) + return locs, len(locs), 1 # 1: factor (see angle_helper) + + +# Tick Formatter + +class FormatterPrettyPrint: + def __init__(self, useMathText=True): + self._fmt = mticker.ScalarFormatter( + useMathText=useMathText, useOffset=False) + self._fmt.create_dummy_axis() + + def __call__(self, direction, factor, values): + return self._fmt.format_ticks(values) + + +class DictFormatter: + def __init__(self, format_dict, formatter=None): + """ + format_dict : dictionary for format strings to be used. + formatter : fall-back formatter + """ + super().__init__() + self._format_dict = format_dict + self._fallback_formatter = formatter + + def __call__(self, direction, factor, values): + """ + factor is ignored if value is found in the dictionary + """ + if self._fallback_formatter: + fallback_strings = self._fallback_formatter( + direction, factor, values) + else: + fallback_strings = [""] * len(values) + return [self._format_dict.get(k, v) + for k, v in zip(values, fallback_strings)] diff --git a/lib/python3.12/site-packages/mpl_toolkits/axisartist/grid_helper_curvelinear.py b/lib/python3.12/site-packages/mpl_toolkits/axisartist/grid_helper_curvelinear.py new file mode 100644 index 0000000000000000000000000000000000000000..a7eb9d5cfe2108c3691e72f527e98b7b73e95a65 --- /dev/null +++ b/lib/python3.12/site-packages/mpl_toolkits/axisartist/grid_helper_curvelinear.py @@ -0,0 +1,328 @@ +""" +An experimental support for curvilinear grid. +""" + +import functools + +import numpy as np + +import matplotlib as mpl +from matplotlib import _api +from matplotlib.path import Path +from matplotlib.transforms import Affine2D, IdentityTransform +from .axislines import ( + _FixedAxisArtistHelperBase, _FloatingAxisArtistHelperBase, GridHelperBase) +from .axis_artist import AxisArtist +from .grid_finder import GridFinder + + +def _value_and_jacobian(func, xs, ys, xlims, ylims): + """ + Compute *func* and its derivatives along x and y at positions *xs*, *ys*, + while ensuring that finite difference calculations don't try to evaluate + values outside of *xlims*, *ylims*. + """ + eps = np.finfo(float).eps ** (1/2) # see e.g. scipy.optimize.approx_fprime + val = func(xs, ys) + # Take the finite difference step in the direction where the bound is the + # furthest; the step size is min of epsilon and distance to that bound. + xlo, xhi = sorted(xlims) + dxlo = xs - xlo + dxhi = xhi - xs + xeps = (np.take([-1, 1], dxhi >= dxlo) + * np.minimum(eps, np.maximum(dxlo, dxhi))) + val_dx = func(xs + xeps, ys) + ylo, yhi = sorted(ylims) + dylo = ys - ylo + dyhi = yhi - ys + yeps = (np.take([-1, 1], dyhi >= dylo) + * np.minimum(eps, np.maximum(dylo, dyhi))) + val_dy = func(xs, ys + yeps) + return (val, (val_dx - val) / xeps, (val_dy - val) / yeps) + + +class FixedAxisArtistHelper(_FixedAxisArtistHelperBase): + """ + Helper class for a fixed axis. + """ + + def __init__(self, grid_helper, side, nth_coord_ticks=None): + """ + nth_coord = along which coordinate value varies. + nth_coord = 0 -> x axis, nth_coord = 1 -> y axis + """ + + super().__init__(loc=side) + + self.grid_helper = grid_helper + if nth_coord_ticks is None: + nth_coord_ticks = self.nth_coord + self.nth_coord_ticks = nth_coord_ticks + + self.side = side + + def update_lim(self, axes): + self.grid_helper.update_lim(axes) + + def get_tick_transform(self, axes): + return axes.transData + + def get_tick_iterators(self, axes): + """tick_loc, tick_angle, tick_label""" + v1, v2 = axes.get_ylim() if self.nth_coord == 0 else axes.get_xlim() + if v1 > v2: # Inverted limits. + side = {"left": "right", "right": "left", + "top": "bottom", "bottom": "top"}[self.side] + else: + side = self.side + + angle_tangent = dict(left=90, right=90, bottom=0, top=0)[side] + + def iter_major(): + for nth_coord, show_labels in [ + (self.nth_coord_ticks, True), (1 - self.nth_coord_ticks, False)]: + gi = self.grid_helper._grid_info[["lon", "lat"][nth_coord]] + for tick in gi["ticks"][side]: + yield (*tick["loc"], angle_tangent, + (tick["label"] if show_labels else "")) + + return iter_major(), iter([]) + + +class FloatingAxisArtistHelper(_FloatingAxisArtistHelperBase): + + def __init__(self, grid_helper, nth_coord, value, axis_direction=None): + """ + nth_coord = along which coordinate value varies. + nth_coord = 0 -> x axis, nth_coord = 1 -> y axis + """ + super().__init__(nth_coord, value) + self.value = value + self.grid_helper = grid_helper + self._extremes = -np.inf, np.inf + self._line_num_points = 100 # number of points to create a line + + def set_extremes(self, e1, e2): + if e1 is None: + e1 = -np.inf + if e2 is None: + e2 = np.inf + self._extremes = e1, e2 + + def update_lim(self, axes): + self.grid_helper.update_lim(axes) + + x1, x2 = axes.get_xlim() + y1, y2 = axes.get_ylim() + grid_finder = self.grid_helper.grid_finder + extremes = grid_finder.extreme_finder(grid_finder.inv_transform_xy, + x1, y1, x2, y2) + + lon_min, lon_max, lat_min, lat_max = extremes + e_min, e_max = self._extremes # ranges of other coordinates + if self.nth_coord == 0: + lat_min = max(e_min, lat_min) + lat_max = min(e_max, lat_max) + elif self.nth_coord == 1: + lon_min = max(e_min, lon_min) + lon_max = min(e_max, lon_max) + + lon_levs, lon_n, lon_factor = \ + grid_finder.grid_locator1(lon_min, lon_max) + lat_levs, lat_n, lat_factor = \ + grid_finder.grid_locator2(lat_min, lat_max) + + if self.nth_coord == 0: + xx0 = np.full(self._line_num_points, self.value) + yy0 = np.linspace(lat_min, lat_max, self._line_num_points) + xx, yy = grid_finder.transform_xy(xx0, yy0) + elif self.nth_coord == 1: + xx0 = np.linspace(lon_min, lon_max, self._line_num_points) + yy0 = np.full(self._line_num_points, self.value) + xx, yy = grid_finder.transform_xy(xx0, yy0) + + self._grid_info = { + "extremes": (lon_min, lon_max, lat_min, lat_max), + "lon_info": (lon_levs, lon_n, np.asarray(lon_factor)), + "lat_info": (lat_levs, lat_n, np.asarray(lat_factor)), + "lon_labels": grid_finder._format_ticks( + 1, "bottom", lon_factor, lon_levs), + "lat_labels": grid_finder._format_ticks( + 2, "bottom", lat_factor, lat_levs), + "line_xy": (xx, yy), + } + + def get_axislabel_transform(self, axes): + return Affine2D() # axes.transData + + def get_axislabel_pos_angle(self, axes): + def trf_xy(x, y): + trf = self.grid_helper.grid_finder.get_transform() + axes.transData + return trf.transform([x, y]).T + + xmin, xmax, ymin, ymax = self._grid_info["extremes"] + if self.nth_coord == 0: + xx0 = self.value + yy0 = (ymin + ymax) / 2 + elif self.nth_coord == 1: + xx0 = (xmin + xmax) / 2 + yy0 = self.value + xy1, dxy1_dx, dxy1_dy = _value_and_jacobian( + trf_xy, xx0, yy0, (xmin, xmax), (ymin, ymax)) + p = axes.transAxes.inverted().transform(xy1) + if 0 <= p[0] <= 1 and 0 <= p[1] <= 1: + d = [dxy1_dy, dxy1_dx][self.nth_coord] + return xy1, np.rad2deg(np.arctan2(*d[::-1])) + else: + return None, None + + def get_tick_transform(self, axes): + return IdentityTransform() # axes.transData + + def get_tick_iterators(self, axes): + """tick_loc, tick_angle, tick_label, (optionally) tick_label""" + + lat_levs, lat_n, lat_factor = self._grid_info["lat_info"] + yy0 = lat_levs / lat_factor + + lon_levs, lon_n, lon_factor = self._grid_info["lon_info"] + xx0 = lon_levs / lon_factor + + e0, e1 = self._extremes + + def trf_xy(x, y): + trf = self.grid_helper.grid_finder.get_transform() + axes.transData + return trf.transform(np.column_stack(np.broadcast_arrays(x, y))).T + + # find angles + if self.nth_coord == 0: + mask = (e0 <= yy0) & (yy0 <= e1) + (xx1, yy1), (dxx1, dyy1), (dxx2, dyy2) = _value_and_jacobian( + trf_xy, self.value, yy0[mask], (-np.inf, np.inf), (e0, e1)) + labels = self._grid_info["lat_labels"] + + elif self.nth_coord == 1: + mask = (e0 <= xx0) & (xx0 <= e1) + (xx1, yy1), (dxx2, dyy2), (dxx1, dyy1) = _value_and_jacobian( + trf_xy, xx0[mask], self.value, (-np.inf, np.inf), (e0, e1)) + labels = self._grid_info["lon_labels"] + + labels = [l for l, m in zip(labels, mask) if m] + + angle_normal = np.arctan2(dyy1, dxx1) + angle_tangent = np.arctan2(dyy2, dxx2) + mm = (dyy1 == 0) & (dxx1 == 0) # points with degenerate normal + angle_normal[mm] = angle_tangent[mm] + np.pi / 2 + + tick_to_axes = self.get_tick_transform(axes) - axes.transAxes + in_01 = functools.partial( + mpl.transforms._interval_contains_close, (0, 1)) + + def iter_major(): + for x, y, normal, tangent, lab \ + in zip(xx1, yy1, angle_normal, angle_tangent, labels): + c2 = tick_to_axes.transform((x, y)) + if in_01(c2[0]) and in_01(c2[1]): + yield [x, y], *np.rad2deg([normal, tangent]), lab + + return iter_major(), iter([]) + + def get_line_transform(self, axes): + return axes.transData + + def get_line(self, axes): + self.update_lim(axes) + x, y = self._grid_info["line_xy"] + return Path(np.column_stack([x, y])) + + +class GridHelperCurveLinear(GridHelperBase): + def __init__(self, aux_trans, + extreme_finder=None, + grid_locator1=None, + grid_locator2=None, + tick_formatter1=None, + tick_formatter2=None): + """ + Parameters + ---------- + aux_trans : `.Transform` or tuple[Callable, Callable] + The transform from curved coordinates to rectilinear coordinate: + either a `.Transform` instance (which provides also its inverse), + or a pair of callables ``(trans, inv_trans)`` that define the + transform and its inverse. The callables should have signature:: + + x_rect, y_rect = trans(x_curved, y_curved) + x_curved, y_curved = inv_trans(x_rect, y_rect) + + extreme_finder + + grid_locator1, grid_locator2 + Grid locators for each axis. + + tick_formatter1, tick_formatter2 + Tick formatters for each axis. + """ + super().__init__() + self._grid_info = None + self.grid_finder = GridFinder(aux_trans, + extreme_finder, + grid_locator1, + grid_locator2, + tick_formatter1, + tick_formatter2) + + def update_grid_finder(self, aux_trans=None, **kwargs): + if aux_trans is not None: + self.grid_finder.update_transform(aux_trans) + self.grid_finder.update(**kwargs) + self._old_limits = None # Force revalidation. + + @_api.make_keyword_only("3.9", "nth_coord") + def new_fixed_axis( + self, loc, nth_coord=None, axis_direction=None, offset=None, axes=None): + if axes is None: + axes = self.axes + if axis_direction is None: + axis_direction = loc + helper = FixedAxisArtistHelper(self, loc, nth_coord_ticks=nth_coord) + axisline = AxisArtist(axes, helper, axis_direction=axis_direction) + # Why is clip not set on axisline, unlike in new_floating_axis or in + # the floating_axig.GridHelperCurveLinear subclass? + return axisline + + def new_floating_axis(self, nth_coord, value, axes=None, axis_direction="bottom"): + if axes is None: + axes = self.axes + helper = FloatingAxisArtistHelper( + self, nth_coord, value, axis_direction) + axisline = AxisArtist(axes, helper) + axisline.line.set_clip_on(True) + axisline.line.set_clip_box(axisline.axes.bbox) + # axisline.major_ticklabels.set_visible(True) + # axisline.minor_ticklabels.set_visible(False) + return axisline + + def _update_grid(self, x1, y1, x2, y2): + self._grid_info = self.grid_finder.get_grid_info(x1, y1, x2, y2) + + def get_gridlines(self, which="major", axis="both"): + grid_lines = [] + if axis in ["both", "x"]: + for gl in self._grid_info["lon"]["lines"]: + grid_lines.extend(gl) + if axis in ["both", "y"]: + for gl in self._grid_info["lat"]["lines"]: + grid_lines.extend(gl) + return grid_lines + + @_api.deprecated("3.9") + def get_tick_iterator(self, nth_coord, axis_side, minor=False): + angle_tangent = dict(left=90, right=90, bottom=0, top=0)[axis_side] + lon_or_lat = ["lon", "lat"][nth_coord] + if not minor: # major ticks + for tick in self._grid_info[lon_or_lat]["ticks"][axis_side]: + yield *tick["loc"], angle_tangent, tick["label"] + else: + for tick in self._grid_info[lon_or_lat]["ticks"][axis_side]: + yield *tick["loc"], angle_tangent, "" diff --git a/lib/python3.12/site-packages/mpl_toolkits/axisartist/parasite_axes.py b/lib/python3.12/site-packages/mpl_toolkits/axisartist/parasite_axes.py new file mode 100644 index 0000000000000000000000000000000000000000..4ebd6acc03be2dbb0f5c3360ede2a6a36a3be01b --- /dev/null +++ b/lib/python3.12/site-packages/mpl_toolkits/axisartist/parasite_axes.py @@ -0,0 +1,7 @@ +from mpl_toolkits.axes_grid1.parasite_axes import ( + host_axes_class_factory, parasite_axes_class_factory) +from .axislines import Axes + + +ParasiteAxes = parasite_axes_class_factory(Axes) +HostAxes = SubplotHost = host_axes_class_factory(Axes) diff --git a/lib/python3.12/site-packages/mpl_toolkits/axisartist/tests/__init__.py b/lib/python3.12/site-packages/mpl_toolkits/axisartist/tests/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..ea4d8ed16a6a24a8c15ab2956ef678a7f256cd80 --- /dev/null +++ b/lib/python3.12/site-packages/mpl_toolkits/axisartist/tests/__init__.py @@ -0,0 +1,10 @@ +from pathlib import Path + + +# Check that the test directories exist +if not (Path(__file__).parent / "baseline_images").exists(): + raise OSError( + 'The baseline image directory does not exist. ' + 'This is most likely because the test data is not installed. ' + 'You may need to install matplotlib from source to get the ' + 'test data.') diff --git a/lib/python3.12/site-packages/mpl_toolkits/axisartist/tests/__pycache__/__init__.cpython-312.pyc b/lib/python3.12/site-packages/mpl_toolkits/axisartist/tests/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e5622044b313333a5307b127a9cea84ffc22a701 Binary files /dev/null and b/lib/python3.12/site-packages/mpl_toolkits/axisartist/tests/__pycache__/__init__.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/mpl_toolkits/axisartist/tests/__pycache__/conftest.cpython-312.pyc b/lib/python3.12/site-packages/mpl_toolkits/axisartist/tests/__pycache__/conftest.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4313de72a1703d1750829596f0703108cde41f81 Binary files /dev/null and b/lib/python3.12/site-packages/mpl_toolkits/axisartist/tests/__pycache__/conftest.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/mpl_toolkits/axisartist/tests/__pycache__/test_angle_helper.cpython-312.pyc b/lib/python3.12/site-packages/mpl_toolkits/axisartist/tests/__pycache__/test_angle_helper.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..045ca5ea5af3c83bceab41cf8691c8fc5898c0b2 Binary files /dev/null and b/lib/python3.12/site-packages/mpl_toolkits/axisartist/tests/__pycache__/test_angle_helper.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/mpl_toolkits/axisartist/tests/__pycache__/test_axis_artist.cpython-312.pyc b/lib/python3.12/site-packages/mpl_toolkits/axisartist/tests/__pycache__/test_axis_artist.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..57a5074d4ab430fe7d69c8084c23d0bfc6447ad2 Binary files /dev/null and b/lib/python3.12/site-packages/mpl_toolkits/axisartist/tests/__pycache__/test_axis_artist.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/mpl_toolkits/axisartist/tests/__pycache__/test_axislines.cpython-312.pyc b/lib/python3.12/site-packages/mpl_toolkits/axisartist/tests/__pycache__/test_axislines.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..75f5c8b6e7b74f5c498e6473a87363c55d44af35 Binary files /dev/null and b/lib/python3.12/site-packages/mpl_toolkits/axisartist/tests/__pycache__/test_axislines.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/mpl_toolkits/axisartist/tests/__pycache__/test_floating_axes.cpython-312.pyc b/lib/python3.12/site-packages/mpl_toolkits/axisartist/tests/__pycache__/test_floating_axes.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8ca187c0d4089b93f598c8217b9d55d948bee431 Binary files /dev/null and b/lib/python3.12/site-packages/mpl_toolkits/axisartist/tests/__pycache__/test_floating_axes.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/mpl_toolkits/axisartist/tests/__pycache__/test_grid_finder.cpython-312.pyc b/lib/python3.12/site-packages/mpl_toolkits/axisartist/tests/__pycache__/test_grid_finder.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3f00bff990291e117b3b79417e2ec9fbde53836d Binary files /dev/null and b/lib/python3.12/site-packages/mpl_toolkits/axisartist/tests/__pycache__/test_grid_finder.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/mpl_toolkits/axisartist/tests/__pycache__/test_grid_helper_curvelinear.cpython-312.pyc b/lib/python3.12/site-packages/mpl_toolkits/axisartist/tests/__pycache__/test_grid_helper_curvelinear.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e84c4daddace4b4ff000698ac5c15c25ffb3e1e7 Binary files /dev/null and b/lib/python3.12/site-packages/mpl_toolkits/axisartist/tests/__pycache__/test_grid_helper_curvelinear.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/mpl_toolkits/axisartist/tests/conftest.py b/lib/python3.12/site-packages/mpl_toolkits/axisartist/tests/conftest.py new file mode 100644 index 0000000000000000000000000000000000000000..61c2de3e07bac4db323f8704961264d123e01544 --- /dev/null +++ b/lib/python3.12/site-packages/mpl_toolkits/axisartist/tests/conftest.py @@ -0,0 +1,2 @@ +from matplotlib.testing.conftest import (mpl_test_settings, # noqa + pytest_configure, pytest_unconfigure) diff --git a/lib/python3.12/site-packages/mpl_toolkits/axisartist/tests/test_angle_helper.py b/lib/python3.12/site-packages/mpl_toolkits/axisartist/tests/test_angle_helper.py new file mode 100644 index 0000000000000000000000000000000000000000..3156b33b69c4f59418f2dc78f700ba105547155a --- /dev/null +++ b/lib/python3.12/site-packages/mpl_toolkits/axisartist/tests/test_angle_helper.py @@ -0,0 +1,141 @@ +import re + +import numpy as np +import pytest + +from mpl_toolkits.axisartist.angle_helper import ( + FormatterDMS, FormatterHMS, select_step, select_step24, select_step360) + + +_MS_RE = ( + r'''\$ # Mathtext + ( + # The sign sometimes appears on a 0 when a fraction is shown. + # Check later that there's only one. + (?P<degree_sign>-)? + (?P<degree>[0-9.]+) # Degrees value + {degree} # Degree symbol (to be replaced by format.) + )? + ( + (?(degree)\\,) # Separator if degrees are also visible. + (?P<minute_sign>-)? + (?P<minute>[0-9.]+) # Minutes value + {minute} # Minute symbol (to be replaced by format.) + )? + ( + (?(minute)\\,) # Separator if minutes are also visible. + (?P<second_sign>-)? + (?P<second>[0-9.]+) # Seconds value + {second} # Second symbol (to be replaced by format.) + )? + \$ # Mathtext + ''' +) +DMS_RE = re.compile(_MS_RE.format(degree=re.escape(FormatterDMS.deg_mark), + minute=re.escape(FormatterDMS.min_mark), + second=re.escape(FormatterDMS.sec_mark)), + re.VERBOSE) +HMS_RE = re.compile(_MS_RE.format(degree=re.escape(FormatterHMS.deg_mark), + minute=re.escape(FormatterHMS.min_mark), + second=re.escape(FormatterHMS.sec_mark)), + re.VERBOSE) + + +def dms2float(degrees, minutes=0, seconds=0): + return degrees + minutes / 60.0 + seconds / 3600.0 + + +@pytest.mark.parametrize('args, kwargs, expected_levels, expected_factor', [ + ((-180, 180, 10), {'hour': False}, np.arange(-180, 181, 30), 1.0), + ((-12, 12, 10), {'hour': True}, np.arange(-12, 13, 2), 1.0) +]) +def test_select_step(args, kwargs, expected_levels, expected_factor): + levels, n, factor = select_step(*args, **kwargs) + + assert n == len(levels) + np.testing.assert_array_equal(levels, expected_levels) + assert factor == expected_factor + + +@pytest.mark.parametrize('args, kwargs, expected_levels, expected_factor', [ + ((-180, 180, 10), {}, np.arange(-180, 181, 30), 1.0), + ((-12, 12, 10), {}, np.arange(-750, 751, 150), 60.0) +]) +def test_select_step24(args, kwargs, expected_levels, expected_factor): + levels, n, factor = select_step24(*args, **kwargs) + + assert n == len(levels) + np.testing.assert_array_equal(levels, expected_levels) + assert factor == expected_factor + + +@pytest.mark.parametrize('args, kwargs, expected_levels, expected_factor', [ + ((dms2float(20, 21.2), dms2float(21, 33.3), 5), {}, + np.arange(1215, 1306, 15), 60.0), + ((dms2float(20.5, seconds=21.2), dms2float(20.5, seconds=33.3), 5), {}, + np.arange(73820, 73835, 2), 3600.0), + ((dms2float(20, 21.2), dms2float(20, 53.3), 5), {}, + np.arange(1220, 1256, 5), 60.0), + ((21.2, 33.3, 5), {}, + np.arange(20, 35, 2), 1.0), + ((dms2float(20, 21.2), dms2float(21, 33.3), 5), {}, + np.arange(1215, 1306, 15), 60.0), + ((dms2float(20.5, seconds=21.2), dms2float(20.5, seconds=33.3), 5), {}, + np.arange(73820, 73835, 2), 3600.0), + ((dms2float(20.5, seconds=21.2), dms2float(20.5, seconds=21.4), 5), {}, + np.arange(7382120, 7382141, 5), 360000.0), + # test threshold factor + ((dms2float(20.5, seconds=11.2), dms2float(20.5, seconds=53.3), 5), + {'threshold_factor': 60}, np.arange(12301, 12310), 600.0), + ((dms2float(20.5, seconds=11.2), dms2float(20.5, seconds=53.3), 5), + {'threshold_factor': 1}, np.arange(20502, 20517, 2), 1000.0), +]) +def test_select_step360(args, kwargs, expected_levels, expected_factor): + levels, n, factor = select_step360(*args, **kwargs) + + assert n == len(levels) + np.testing.assert_array_equal(levels, expected_levels) + assert factor == expected_factor + + +@pytest.mark.parametrize('Formatter, regex', + [(FormatterDMS, DMS_RE), + (FormatterHMS, HMS_RE)], + ids=['Degree/Minute/Second', 'Hour/Minute/Second']) +@pytest.mark.parametrize('direction, factor, values', [ + ("left", 60, [0, -30, -60]), + ("left", 600, [12301, 12302, 12303]), + ("left", 3600, [0, -30, -60]), + ("left", 36000, [738210, 738215, 738220]), + ("left", 360000, [7382120, 7382125, 7382130]), + ("left", 1., [45, 46, 47]), + ("left", 10., [452, 453, 454]), +]) +def test_formatters(Formatter, regex, direction, factor, values): + fmt = Formatter() + result = fmt(direction, factor, values) + + prev_degree = prev_minute = prev_second = None + for tick, value in zip(result, values): + m = regex.match(tick) + assert m is not None, f'{tick!r} is not an expected tick format.' + + sign = sum(m.group(sign + '_sign') is not None + for sign in ('degree', 'minute', 'second')) + assert sign <= 1, f'Only one element of tick {tick!r} may have a sign.' + sign = 1 if sign == 0 else -1 + + degree = float(m.group('degree') or prev_degree or 0) + minute = float(m.group('minute') or prev_minute or 0) + second = float(m.group('second') or prev_second or 0) + if Formatter == FormatterHMS: + # 360 degrees as plot range -> 24 hours as labelled range + expected_value = pytest.approx((value // 15) / factor) + else: + expected_value = pytest.approx(value / factor) + assert sign * dms2float(degree, minute, second) == expected_value, \ + f'{tick!r} does not match expected tick value.' + + prev_degree = degree + prev_minute = minute + prev_second = second diff --git a/lib/python3.12/site-packages/mpl_toolkits/axisartist/tests/test_axis_artist.py b/lib/python3.12/site-packages/mpl_toolkits/axisartist/tests/test_axis_artist.py new file mode 100644 index 0000000000000000000000000000000000000000..d44a61b6dd4aebebd14f0e240c130e390b0bc70b --- /dev/null +++ b/lib/python3.12/site-packages/mpl_toolkits/axisartist/tests/test_axis_artist.py @@ -0,0 +1,99 @@ +import matplotlib.pyplot as plt +from matplotlib.testing.decorators import image_comparison + +from mpl_toolkits.axisartist import AxisArtistHelperRectlinear +from mpl_toolkits.axisartist.axis_artist import (AxisArtist, AxisLabel, + LabelBase, Ticks, TickLabels) + + +@image_comparison(['axis_artist_ticks.png'], style='default') +def test_ticks(): + fig, ax = plt.subplots() + + ax.xaxis.set_visible(False) + ax.yaxis.set_visible(False) + + locs_angles = [((i / 10, 0.0), i * 30) for i in range(-1, 12)] + + ticks_in = Ticks(ticksize=10, axis=ax.xaxis) + ticks_in.set_locs_angles(locs_angles) + ax.add_artist(ticks_in) + + ticks_out = Ticks(ticksize=10, tick_out=True, color='C3', axis=ax.xaxis) + ticks_out.set_locs_angles(locs_angles) + ax.add_artist(ticks_out) + + +@image_comparison(['axis_artist_labelbase.png'], style='default') +def test_labelbase(): + # Remove this line when this test image is regenerated. + plt.rcParams['text.kerning_factor'] = 6 + + fig, ax = plt.subplots() + + ax.plot([0.5], [0.5], "o") + + label = LabelBase(0.5, 0.5, "Test") + label._ref_angle = -90 + label._offset_radius = 50 + label.set_rotation(-90) + label.set(ha="center", va="top") + ax.add_artist(label) + + +@image_comparison(['axis_artist_ticklabels.png'], style='default') +def test_ticklabels(): + # Remove this line when this test image is regenerated. + plt.rcParams['text.kerning_factor'] = 6 + + fig, ax = plt.subplots() + + ax.xaxis.set_visible(False) + ax.yaxis.set_visible(False) + + ax.plot([0.2, 0.4], [0.5, 0.5], "o") + + ticks = Ticks(ticksize=10, axis=ax.xaxis) + ax.add_artist(ticks) + locs_angles_labels = [((0.2, 0.5), -90, "0.2"), + ((0.4, 0.5), -120, "0.4")] + tick_locs_angles = [(xy, a + 180) for xy, a, l in locs_angles_labels] + ticks.set_locs_angles(tick_locs_angles) + + ticklabels = TickLabels(axis_direction="left") + ticklabels._locs_angles_labels = locs_angles_labels + ticklabels.set_pad(10) + ax.add_artist(ticklabels) + + ax.plot([0.5], [0.5], "s") + axislabel = AxisLabel(0.5, 0.5, "Test") + axislabel._offset_radius = 20 + axislabel._ref_angle = 0 + axislabel.set_axis_direction("bottom") + ax.add_artist(axislabel) + + ax.set_xlim(0, 1) + ax.set_ylim(0, 1) + + +@image_comparison(['axis_artist.png'], style='default') +def test_axis_artist(): + # Remove this line when this test image is regenerated. + plt.rcParams['text.kerning_factor'] = 6 + + fig, ax = plt.subplots() + + ax.xaxis.set_visible(False) + ax.yaxis.set_visible(False) + + for loc in ('left', 'right', 'bottom'): + helper = AxisArtistHelperRectlinear.Fixed(ax, loc=loc) + axisline = AxisArtist(ax, helper, offset=None, axis_direction=loc) + ax.add_artist(axisline) + + # Settings for bottom AxisArtist. + axisline.set_label("TTT") + axisline.major_ticks.set_tick_out(False) + axisline.label.set_pad(5) + + ax.set_ylabel("Test") diff --git a/lib/python3.12/site-packages/mpl_toolkits/axisartist/tests/test_axislines.py b/lib/python3.12/site-packages/mpl_toolkits/axisartist/tests/test_axislines.py new file mode 100644 index 0000000000000000000000000000000000000000..8bc3707421b61fed84a5ba7fc3958cac7de8dc85 --- /dev/null +++ b/lib/python3.12/site-packages/mpl_toolkits/axisartist/tests/test_axislines.py @@ -0,0 +1,145 @@ +import numpy as np +import matplotlib.pyplot as plt +from matplotlib.testing.decorators import image_comparison +from matplotlib.transforms import IdentityTransform + +from mpl_toolkits.axisartist.axislines import AxesZero, SubplotZero, Subplot +from mpl_toolkits.axisartist import Axes, SubplotHost + + +@image_comparison(['SubplotZero.png'], style='default') +def test_SubplotZero(): + # Remove this line when this test image is regenerated. + plt.rcParams['text.kerning_factor'] = 6 + + fig = plt.figure() + + ax = SubplotZero(fig, 1, 1, 1) + fig.add_subplot(ax) + + ax.axis["xzero"].set_visible(True) + ax.axis["xzero"].label.set_text("Axis Zero") + + for n in ["top", "right"]: + ax.axis[n].set_visible(False) + + xx = np.arange(0, 2 * np.pi, 0.01) + ax.plot(xx, np.sin(xx)) + ax.set_ylabel("Test") + + +@image_comparison(['Subplot.png'], style='default') +def test_Subplot(): + # Remove this line when this test image is regenerated. + plt.rcParams['text.kerning_factor'] = 6 + + fig = plt.figure() + + ax = Subplot(fig, 1, 1, 1) + fig.add_subplot(ax) + + xx = np.arange(0, 2 * np.pi, 0.01) + ax.plot(xx, np.sin(xx)) + ax.set_ylabel("Test") + + ax.axis["top"].major_ticks.set_tick_out(True) + ax.axis["bottom"].major_ticks.set_tick_out(True) + + ax.axis["bottom"].set_label("Tk0") + + +def test_Axes(): + fig = plt.figure() + ax = Axes(fig, [0.15, 0.1, 0.65, 0.8]) + fig.add_axes(ax) + ax.plot([1, 2, 3], [0, 1, 2]) + ax.set_xscale('log') + fig.canvas.draw() + + +@image_comparison(['ParasiteAxesAuxTrans_meshplot.png'], + remove_text=True, style='default', tol=0.075) +def test_ParasiteAxesAuxTrans(): + data = np.ones((6, 6)) + data[2, 2] = 2 + data[0, :] = 0 + data[-2, :] = 0 + data[:, 0] = 0 + data[:, -2] = 0 + x = np.arange(6) + y = np.arange(6) + xx, yy = np.meshgrid(x, y) + + funcnames = ['pcolor', 'pcolormesh', 'contourf'] + + fig = plt.figure() + for i, name in enumerate(funcnames): + + ax1 = SubplotHost(fig, 1, 3, i+1) + fig.add_subplot(ax1) + + ax2 = ax1.get_aux_axes(IdentityTransform(), viewlim_mode=None) + if name.startswith('pcolor'): + getattr(ax2, name)(xx, yy, data[:-1, :-1]) + else: + getattr(ax2, name)(xx, yy, data) + ax1.set_xlim((0, 5)) + ax1.set_ylim((0, 5)) + + ax2.contour(xx, yy, data, colors='k') + + +@image_comparison(['axisline_style.png'], remove_text=True, style='mpl20') +def test_axisline_style(): + fig = plt.figure(figsize=(2, 2)) + ax = fig.add_subplot(axes_class=AxesZero) + ax.axis["xzero"].set_axisline_style("-|>") + ax.axis["xzero"].set_visible(True) + ax.axis["yzero"].set_axisline_style("->") + ax.axis["yzero"].set_visible(True) + + for direction in ("left", "right", "bottom", "top"): + ax.axis[direction].set_visible(False) + + +@image_comparison(['axisline_style_size_color.png'], remove_text=True, + style='mpl20') +def test_axisline_style_size_color(): + fig = plt.figure(figsize=(2, 2)) + ax = fig.add_subplot(axes_class=AxesZero) + ax.axis["xzero"].set_axisline_style("-|>", size=2.0, facecolor='r') + ax.axis["xzero"].set_visible(True) + ax.axis["yzero"].set_axisline_style("->, size=1.5") + ax.axis["yzero"].set_visible(True) + + for direction in ("left", "right", "bottom", "top"): + ax.axis[direction].set_visible(False) + + +@image_comparison(['axisline_style_tight.png'], remove_text=True, + style='mpl20') +def test_axisline_style_tight(): + fig = plt.figure(figsize=(2, 2), layout='tight') + ax = fig.add_subplot(axes_class=AxesZero) + ax.axis["xzero"].set_axisline_style("-|>", size=5, facecolor='g') + ax.axis["xzero"].set_visible(True) + ax.axis["yzero"].set_axisline_style("->, size=8") + ax.axis["yzero"].set_visible(True) + + for direction in ("left", "right", "bottom", "top"): + ax.axis[direction].set_visible(False) + + +@image_comparison(['subplotzero_ylabel.png'], style='mpl20') +def test_subplotzero_ylabel(): + fig = plt.figure() + ax = fig.add_subplot(111, axes_class=SubplotZero) + + ax.set(xlim=(-3, 7), ylim=(-3, 7), xlabel="x", ylabel="y") + + zero_axis = ax.axis["xzero", "yzero"] + zero_axis.set_visible(True) # they are hidden by default + + ax.axis["left", "right", "bottom", "top"].set_visible(False) + + zero_axis.set_axisline_style("->") diff --git a/lib/python3.12/site-packages/mpl_toolkits/axisartist/tests/test_floating_axes.py b/lib/python3.12/site-packages/mpl_toolkits/axisartist/tests/test_floating_axes.py new file mode 100644 index 0000000000000000000000000000000000000000..7644fea1696599273f6214d7735bb919b500108c --- /dev/null +++ b/lib/python3.12/site-packages/mpl_toolkits/axisartist/tests/test_floating_axes.py @@ -0,0 +1,115 @@ +import numpy as np + +import matplotlib.pyplot as plt +import matplotlib.projections as mprojections +import matplotlib.transforms as mtransforms +from matplotlib.testing.decorators import image_comparison +from mpl_toolkits.axisartist.axislines import Subplot +from mpl_toolkits.axisartist.floating_axes import ( + FloatingAxes, GridHelperCurveLinear) +from mpl_toolkits.axisartist.grid_finder import FixedLocator +from mpl_toolkits.axisartist import angle_helper + + +def test_subplot(): + fig = plt.figure(figsize=(5, 5)) + ax = Subplot(fig, 111) + fig.add_subplot(ax) + + +# Rather high tolerance to allow ongoing work with floating axes internals; +# remove when image is regenerated. +@image_comparison(['curvelinear3.png'], style='default', tol=5) +def test_curvelinear3(): + fig = plt.figure(figsize=(5, 5)) + + tr = (mtransforms.Affine2D().scale(np.pi / 180, 1) + + mprojections.PolarAxes.PolarTransform(apply_theta_transforms=False)) + grid_helper = GridHelperCurveLinear( + tr, + extremes=(0, 360, 10, 3), + grid_locator1=angle_helper.LocatorDMS(15), + grid_locator2=FixedLocator([2, 4, 6, 8, 10]), + tick_formatter1=angle_helper.FormatterDMS(), + tick_formatter2=None) + ax1 = fig.add_subplot(axes_class=FloatingAxes, grid_helper=grid_helper) + + r_scale = 10 + tr2 = mtransforms.Affine2D().scale(1, 1 / r_scale) + tr + grid_helper2 = GridHelperCurveLinear( + tr2, + extremes=(0, 360, 10 * r_scale, 3 * r_scale), + grid_locator2=FixedLocator([30, 60, 90])) + + ax1.axis["right"] = axis = grid_helper2.new_fixed_axis("right", axes=ax1) + + ax1.axis["left"].label.set_text("Test 1") + ax1.axis["right"].label.set_text("Test 2") + ax1.axis["left", "right"].set_visible(False) + + axis = grid_helper.new_floating_axis(1, 7, axes=ax1, + axis_direction="bottom") + ax1.axis["z"] = axis + axis.toggle(all=True, label=True) + axis.label.set_text("z = ?") + axis.label.set_visible(True) + axis.line.set_color("0.5") + + ax2 = ax1.get_aux_axes(tr) + + xx, yy = [67, 90, 75, 30], [2, 5, 8, 4] + ax2.scatter(xx, yy) + l, = ax2.plot(xx, yy, "k-") + l.set_clip_path(ax1.patch) + + +# Rather high tolerance to allow ongoing work with floating axes internals; +# remove when image is regenerated. +@image_comparison(['curvelinear4.png'], style='default', tol=0.9) +def test_curvelinear4(): + # Remove this line when this test image is regenerated. + plt.rcParams['text.kerning_factor'] = 6 + + fig = plt.figure(figsize=(5, 5)) + + tr = (mtransforms.Affine2D().scale(np.pi / 180, 1) + + mprojections.PolarAxes.PolarTransform(apply_theta_transforms=False)) + grid_helper = GridHelperCurveLinear( + tr, + extremes=(120, 30, 10, 0), + grid_locator1=angle_helper.LocatorDMS(5), + grid_locator2=FixedLocator([2, 4, 6, 8, 10]), + tick_formatter1=angle_helper.FormatterDMS(), + tick_formatter2=None) + ax1 = fig.add_subplot(axes_class=FloatingAxes, grid_helper=grid_helper) + ax1.clear() # Check that clear() also restores the correct limits on ax1. + + ax1.axis["left"].label.set_text("Test 1") + ax1.axis["right"].label.set_text("Test 2") + ax1.axis["top"].set_visible(False) + + axis = grid_helper.new_floating_axis(1, 70, axes=ax1, + axis_direction="bottom") + ax1.axis["z"] = axis + axis.toggle(all=True, label=True) + axis.label.set_axis_direction("top") + axis.label.set_text("z = ?") + axis.label.set_visible(True) + axis.line.set_color("0.5") + + ax2 = ax1.get_aux_axes(tr) + + xx, yy = [67, 90, 75, 30], [2, 5, 8, 4] + ax2.scatter(xx, yy) + l, = ax2.plot(xx, yy, "k-") + l.set_clip_path(ax1.patch) + + +def test_axis_direction(): + # Check that axis direction is propagated on a floating axis + fig = plt.figure() + ax = Subplot(fig, 111) + fig.add_subplot(ax) + ax.axis['y'] = ax.new_floating_axis(nth_coord=1, value=0, + axis_direction='left') + assert ax.axis['y']._axis_direction == 'left' diff --git a/lib/python3.12/site-packages/mpl_toolkits/axisartist/tests/test_grid_finder.py b/lib/python3.12/site-packages/mpl_toolkits/axisartist/tests/test_grid_finder.py new file mode 100644 index 0000000000000000000000000000000000000000..6b397675ee10098a93545cc712a4b3c07384cbc2 --- /dev/null +++ b/lib/python3.12/site-packages/mpl_toolkits/axisartist/tests/test_grid_finder.py @@ -0,0 +1,34 @@ +import numpy as np +import pytest + +from matplotlib.transforms import Bbox +from mpl_toolkits.axisartist.grid_finder import ( + _find_line_box_crossings, FormatterPrettyPrint, MaxNLocator) + + +def test_find_line_box_crossings(): + x = np.array([-3, -2, -1, 0., 1, 2, 3, 2, 1, 0, -1, -2, -3, 5]) + y = np.arange(len(x)) + bbox = Bbox.from_extents(-2, 3, 2, 12.5) + left, right, bottom, top = _find_line_box_crossings( + np.column_stack([x, y]), bbox) + ((lx0, ly0), la0), ((lx1, ly1), la1), = left + ((rx0, ry0), ra0), ((rx1, ry1), ra1), = right + ((bx0, by0), ba0), = bottom + ((tx0, ty0), ta0), = top + assert (lx0, ly0, la0) == (-2, 11, 135) + assert (lx1, ly1, la1) == pytest.approx((-2., 12.125, 7.125016)) + assert (rx0, ry0, ra0) == (2, 5, 45) + assert (rx1, ry1, ra1) == (2, 7, 135) + assert (bx0, by0, ba0) == (0, 3, 45) + assert (tx0, ty0, ta0) == pytest.approx((1., 12.5, 7.125016)) + + +def test_pretty_print_format(): + locator = MaxNLocator() + locs, nloc, factor = locator(0, 100) + + fmt = FormatterPrettyPrint() + + assert fmt("left", None, locs) == \ + [r'$\mathdefault{%d}$' % (l, ) for l in locs] diff --git a/lib/python3.12/site-packages/mpl_toolkits/axisartist/tests/test_grid_helper_curvelinear.py b/lib/python3.12/site-packages/mpl_toolkits/axisartist/tests/test_grid_helper_curvelinear.py new file mode 100644 index 0000000000000000000000000000000000000000..1b266044bdd06d995a1df86779dac97dae8cb13e --- /dev/null +++ b/lib/python3.12/site-packages/mpl_toolkits/axisartist/tests/test_grid_helper_curvelinear.py @@ -0,0 +1,207 @@ +import numpy as np + +import matplotlib.pyplot as plt +from matplotlib.path import Path +from matplotlib.projections import PolarAxes +from matplotlib.ticker import FuncFormatter +from matplotlib.transforms import Affine2D, Transform +from matplotlib.testing.decorators import image_comparison + +from mpl_toolkits.axisartist import SubplotHost +from mpl_toolkits.axes_grid1.parasite_axes import host_axes_class_factory +from mpl_toolkits.axisartist import angle_helper +from mpl_toolkits.axisartist.axislines import Axes +from mpl_toolkits.axisartist.grid_helper_curvelinear import \ + GridHelperCurveLinear + + +@image_comparison(['custom_transform.png'], style='default', tol=0.2) +def test_custom_transform(): + class MyTransform(Transform): + input_dims = output_dims = 2 + + def __init__(self, resolution): + """ + Resolution is the number of steps to interpolate between each input + line segment to approximate its path in transformed space. + """ + Transform.__init__(self) + self._resolution = resolution + + def transform(self, ll): + x, y = ll.T + return np.column_stack([x, y - x]) + + transform_non_affine = transform + + def transform_path(self, path): + ipath = path.interpolated(self._resolution) + return Path(self.transform(ipath.vertices), ipath.codes) + + transform_path_non_affine = transform_path + + def inverted(self): + return MyTransformInv(self._resolution) + + class MyTransformInv(Transform): + input_dims = output_dims = 2 + + def __init__(self, resolution): + Transform.__init__(self) + self._resolution = resolution + + def transform(self, ll): + x, y = ll.T + return np.column_stack([x, y + x]) + + def inverted(self): + return MyTransform(self._resolution) + + fig = plt.figure() + + SubplotHost = host_axes_class_factory(Axes) + + tr = MyTransform(1) + grid_helper = GridHelperCurveLinear(tr) + ax1 = SubplotHost(fig, 1, 1, 1, grid_helper=grid_helper) + fig.add_subplot(ax1) + + ax2 = ax1.get_aux_axes(tr, viewlim_mode="equal") + ax2.plot([3, 6], [5.0, 10.]) + + ax1.set_aspect(1.) + ax1.set_xlim(0, 10) + ax1.set_ylim(0, 10) + + ax1.grid(True) + + +@image_comparison(['polar_box.png'], style='default', tol=0.04) +def test_polar_box(): + fig = plt.figure(figsize=(5, 5)) + + # PolarAxes.PolarTransform takes radian. However, we want our coordinate + # system in degree + tr = (Affine2D().scale(np.pi / 180., 1.) + + PolarAxes.PolarTransform(apply_theta_transforms=False)) + + # polar projection, which involves cycle, and also has limits in + # its coordinates, needs a special method to find the extremes + # (min, max of the coordinate within the view). + extreme_finder = angle_helper.ExtremeFinderCycle(20, 20, + lon_cycle=360, + lat_cycle=None, + lon_minmax=None, + lat_minmax=(0, np.inf)) + + grid_helper = GridHelperCurveLinear( + tr, + extreme_finder=extreme_finder, + grid_locator1=angle_helper.LocatorDMS(12), + tick_formatter1=angle_helper.FormatterDMS(), + tick_formatter2=FuncFormatter(lambda x, p: "eight" if x == 8 else f"{int(x)}"), + ) + + ax1 = SubplotHost(fig, 1, 1, 1, grid_helper=grid_helper) + + ax1.axis["right"].major_ticklabels.set_visible(True) + ax1.axis["top"].major_ticklabels.set_visible(True) + + # let right axis shows ticklabels for 1st coordinate (angle) + ax1.axis["right"].get_helper().nth_coord_ticks = 0 + # let bottom axis shows ticklabels for 2nd coordinate (radius) + ax1.axis["bottom"].get_helper().nth_coord_ticks = 1 + + fig.add_subplot(ax1) + + ax1.axis["lat"] = axis = grid_helper.new_floating_axis(0, 45, axes=ax1) + axis.label.set_text("Test") + axis.label.set_visible(True) + axis.get_helper().set_extremes(2, 12) + + ax1.axis["lon"] = axis = grid_helper.new_floating_axis(1, 6, axes=ax1) + axis.label.set_text("Test 2") + axis.get_helper().set_extremes(-180, 90) + + # A parasite axes with given transform + ax2 = ax1.get_aux_axes(tr, viewlim_mode="equal") + assert ax2.transData == tr + ax1.transData + # Anything you draw in ax2 will match the ticks and grids of ax1. + ax2.plot(np.linspace(0, 30, 50), np.linspace(10, 10, 50)) + + ax1.set_aspect(1.) + ax1.set_xlim(-5, 12) + ax1.set_ylim(-5, 10) + + ax1.grid(True) + + +# Remove tol & kerning_factor when this test image is regenerated. +@image_comparison(['axis_direction.png'], style='default', tol=0.13) +def test_axis_direction(): + plt.rcParams['text.kerning_factor'] = 6 + + fig = plt.figure(figsize=(5, 5)) + + # PolarAxes.PolarTransform takes radian. However, we want our coordinate + # system in degree + tr = (Affine2D().scale(np.pi / 180., 1.) + + PolarAxes.PolarTransform(apply_theta_transforms=False)) + + # polar projection, which involves cycle, and also has limits in + # its coordinates, needs a special method to find the extremes + # (min, max of the coordinate within the view). + + # 20, 20 : number of sampling points along x, y direction + extreme_finder = angle_helper.ExtremeFinderCycle(20, 20, + lon_cycle=360, + lat_cycle=None, + lon_minmax=None, + lat_minmax=(0, np.inf), + ) + + grid_locator1 = angle_helper.LocatorDMS(12) + tick_formatter1 = angle_helper.FormatterDMS() + + grid_helper = GridHelperCurveLinear(tr, + extreme_finder=extreme_finder, + grid_locator1=grid_locator1, + tick_formatter1=tick_formatter1) + + ax1 = SubplotHost(fig, 1, 1, 1, grid_helper=grid_helper) + + for axis in ax1.axis.values(): + axis.set_visible(False) + + fig.add_subplot(ax1) + + ax1.axis["lat1"] = axis = grid_helper.new_floating_axis( + 0, 130, + axes=ax1, axis_direction="left") + axis.label.set_text("Test") + axis.label.set_visible(True) + axis.get_helper().set_extremes(0.001, 10) + + ax1.axis["lat2"] = axis = grid_helper.new_floating_axis( + 0, 50, + axes=ax1, axis_direction="right") + axis.label.set_text("Test") + axis.label.set_visible(True) + axis.get_helper().set_extremes(0.001, 10) + + ax1.axis["lon"] = axis = grid_helper.new_floating_axis( + 1, 10, + axes=ax1, axis_direction="bottom") + axis.label.set_text("Test 2") + axis.get_helper().set_extremes(50, 130) + axis.major_ticklabels.set_axis_direction("top") + axis.label.set_axis_direction("top") + + grid_helper.grid_finder.grid_locator1.set_params(nbins=5) + grid_helper.grid_finder.grid_locator2.set_params(nbins=5) + + ax1.set_aspect(1.) + ax1.set_xlim(-8, 8) + ax1.set_ylim(-4, 12) + + ax1.grid(True) diff --git a/lib/python3.12/site-packages/mpl_toolkits/mplot3d/__init__.py b/lib/python3.12/site-packages/mpl_toolkits/mplot3d/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..a089fbd6b70ebdafb19f6c902d49e5e3bdc05f43 --- /dev/null +++ b/lib/python3.12/site-packages/mpl_toolkits/mplot3d/__init__.py @@ -0,0 +1,3 @@ +from .axes3d import Axes3D + +__all__ = ['Axes3D'] diff --git a/lib/python3.12/site-packages/mpl_toolkits/mplot3d/__pycache__/__init__.cpython-312.pyc b/lib/python3.12/site-packages/mpl_toolkits/mplot3d/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7d0e63c8e8d116bae76cd745ac6f4ec3dd769a8f Binary files /dev/null and b/lib/python3.12/site-packages/mpl_toolkits/mplot3d/__pycache__/__init__.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/mpl_toolkits/mplot3d/__pycache__/art3d.cpython-312.pyc b/lib/python3.12/site-packages/mpl_toolkits/mplot3d/__pycache__/art3d.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..451830c32af69e80cf89e2a758e01f2273bed329 Binary files /dev/null and b/lib/python3.12/site-packages/mpl_toolkits/mplot3d/__pycache__/art3d.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/mpl_toolkits/mplot3d/__pycache__/axis3d.cpython-312.pyc b/lib/python3.12/site-packages/mpl_toolkits/mplot3d/__pycache__/axis3d.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bec9d86252e88143b59e5764357c982499d25521 Binary files /dev/null and b/lib/python3.12/site-packages/mpl_toolkits/mplot3d/__pycache__/axis3d.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/mpl_toolkits/mplot3d/__pycache__/proj3d.cpython-312.pyc b/lib/python3.12/site-packages/mpl_toolkits/mplot3d/__pycache__/proj3d.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..78d520636be2407f12a8c62807d3f99ae9ad1714 Binary files /dev/null and b/lib/python3.12/site-packages/mpl_toolkits/mplot3d/__pycache__/proj3d.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/mpl_toolkits/mplot3d/art3d.py b/lib/python3.12/site-packages/mpl_toolkits/mplot3d/art3d.py new file mode 100644 index 0000000000000000000000000000000000000000..deb0ca34302c03cda08e57b28a82cb3654e45aaa --- /dev/null +++ b/lib/python3.12/site-packages/mpl_toolkits/mplot3d/art3d.py @@ -0,0 +1,1427 @@ +# art3d.py, original mplot3d version by John Porter +# Parts rewritten by Reinier Heeres <reinier@heeres.eu> +# Minor additions by Ben Axelrod <baxelrod@coroware.com> + +""" +Module containing 3D artist code and functions to convert 2D +artists into 3D versions which can be added to an Axes3D. +""" + +import math + +import numpy as np + +from contextlib import contextmanager + +from matplotlib import ( + _api, artist, cbook, colors as mcolors, lines, text as mtext, + path as mpath) +from matplotlib.collections import ( + Collection, LineCollection, PolyCollection, PatchCollection, PathCollection) +from matplotlib.colors import Normalize +from matplotlib.patches import Patch +from . import proj3d + + +def _norm_angle(a): + """Return the given angle normalized to -180 < *a* <= 180 degrees.""" + a = (a + 360) % 360 + if a > 180: + a = a - 360 + return a + + +def _norm_text_angle(a): + """Return the given angle normalized to -90 < *a* <= 90 degrees.""" + a = (a + 180) % 180 + if a > 90: + a = a - 180 + return a + + +def get_dir_vector(zdir): + """ + Return a direction vector. + + Parameters + ---------- + zdir : {'x', 'y', 'z', None, 3-tuple} + The direction. Possible values are: + + - 'x': equivalent to (1, 0, 0) + - 'y': equivalent to (0, 1, 0) + - 'z': equivalent to (0, 0, 1) + - *None*: equivalent to (0, 0, 0) + - an iterable (x, y, z) is converted to an array + + Returns + ------- + x, y, z : array + The direction vector. + """ + if zdir == 'x': + return np.array((1, 0, 0)) + elif zdir == 'y': + return np.array((0, 1, 0)) + elif zdir == 'z': + return np.array((0, 0, 1)) + elif zdir is None: + return np.array((0, 0, 0)) + elif np.iterable(zdir) and len(zdir) == 3: + return np.array(zdir) + else: + raise ValueError("'x', 'y', 'z', None or vector of length 3 expected") + + +def _viewlim_mask(xs, ys, zs, axes): + """ + Return original points with points outside the axes view limits masked. + + Parameters + ---------- + xs, ys, zs : array-like + The points to mask. + axes : Axes3D + The axes to use for the view limits. + + Returns + ------- + xs_masked, ys_masked, zs_masked : np.ma.array + The masked points. + """ + mask = np.logical_or.reduce((xs < axes.xy_viewLim.xmin, + xs > axes.xy_viewLim.xmax, + ys < axes.xy_viewLim.ymin, + ys > axes.xy_viewLim.ymax, + zs < axes.zz_viewLim.xmin, + zs > axes.zz_viewLim.xmax)) + xs_masked = np.ma.array(xs, mask=mask) + ys_masked = np.ma.array(ys, mask=mask) + zs_masked = np.ma.array(zs, mask=mask) + return xs_masked, ys_masked, zs_masked + + +class Text3D(mtext.Text): + """ + Text object with 3D position and direction. + + Parameters + ---------- + x, y, z : float + The position of the text. + text : str + The text string to display. + zdir : {'x', 'y', 'z', None, 3-tuple} + The direction of the text. See `.get_dir_vector` for a description of + the values. + axlim_clip : bool, default: False + Whether to hide text outside the axes view limits. + + Other Parameters + ---------------- + **kwargs + All other parameters are passed on to `~matplotlib.text.Text`. + """ + + def __init__(self, x=0, y=0, z=0, text='', zdir='z', axlim_clip=False, + **kwargs): + mtext.Text.__init__(self, x, y, text, **kwargs) + self.set_3d_properties(z, zdir, axlim_clip) + + def get_position_3d(self): + """Return the (x, y, z) position of the text.""" + return self._x, self._y, self._z + + def set_position_3d(self, xyz, zdir=None): + """ + Set the (*x*, *y*, *z*) position of the text. + + Parameters + ---------- + xyz : (float, float, float) + The position in 3D space. + zdir : {'x', 'y', 'z', None, 3-tuple} + The direction of the text. If unspecified, the *zdir* will not be + changed. See `.get_dir_vector` for a description of the values. + """ + super().set_position(xyz[:2]) + self.set_z(xyz[2]) + if zdir is not None: + self._dir_vec = get_dir_vector(zdir) + + def set_z(self, z): + """ + Set the *z* position of the text. + + Parameters + ---------- + z : float + """ + self._z = z + self.stale = True + + def set_3d_properties(self, z=0, zdir='z', axlim_clip=False): + """ + Set the *z* position and direction of the text. + + Parameters + ---------- + z : float + The z-position in 3D space. + zdir : {'x', 'y', 'z', 3-tuple} + The direction of the text. Default: 'z'. + See `.get_dir_vector` for a description of the values. + axlim_clip : bool, default: False + Whether to hide text outside the axes view limits. + """ + self._z = z + self._dir_vec = get_dir_vector(zdir) + self._axlim_clip = axlim_clip + self.stale = True + + @artist.allow_rasterization + def draw(self, renderer): + if self._axlim_clip: + xs, ys, zs = _viewlim_mask(self._x, self._y, self._z, self.axes) + position3d = np.ma.row_stack((xs, ys, zs)).ravel().filled(np.nan) + else: + xs, ys, zs = self._x, self._y, self._z + position3d = np.asanyarray([xs, ys, zs]) + + proj = proj3d._proj_trans_points( + [position3d, position3d + self._dir_vec], self.axes.M) + dx = proj[0][1] - proj[0][0] + dy = proj[1][1] - proj[1][0] + angle = math.degrees(math.atan2(dy, dx)) + with cbook._setattr_cm(self, _x=proj[0][0], _y=proj[1][0], + _rotation=_norm_text_angle(angle)): + mtext.Text.draw(self, renderer) + self.stale = False + + def get_tightbbox(self, renderer=None): + # Overwriting the 2d Text behavior which is not valid for 3d. + # For now, just return None to exclude from layout calculation. + return None + + +def text_2d_to_3d(obj, z=0, zdir='z', axlim_clip=False): + """ + Convert a `.Text` to a `.Text3D` object. + + Parameters + ---------- + z : float + The z-position in 3D space. + zdir : {'x', 'y', 'z', 3-tuple} + The direction of the text. Default: 'z'. + See `.get_dir_vector` for a description of the values. + axlim_clip : bool, default: False + Whether to hide text outside the axes view limits. + """ + obj.__class__ = Text3D + obj.set_3d_properties(z, zdir, axlim_clip) + + +class Line3D(lines.Line2D): + """ + 3D line object. + + .. note:: Use `get_data_3d` to obtain the data associated with the line. + `~.Line2D.get_data`, `~.Line2D.get_xdata`, and `~.Line2D.get_ydata` return + the x- and y-coordinates of the projected 2D-line, not the x- and y-data of + the 3D-line. Similarly, use `set_data_3d` to set the data, not + `~.Line2D.set_data`, `~.Line2D.set_xdata`, and `~.Line2D.set_ydata`. + """ + + def __init__(self, xs, ys, zs, *args, axlim_clip=False, **kwargs): + """ + + Parameters + ---------- + xs : array-like + The x-data to be plotted. + ys : array-like + The y-data to be plotted. + zs : array-like + The z-data to be plotted. + *args, **kwargs + Additional arguments are passed to `~matplotlib.lines.Line2D`. + """ + super().__init__([], [], *args, **kwargs) + self.set_data_3d(xs, ys, zs) + self._axlim_clip = axlim_clip + + def set_3d_properties(self, zs=0, zdir='z', axlim_clip=False): + """ + Set the *z* position and direction of the line. + + Parameters + ---------- + zs : float or array of floats + The location along the *zdir* axis in 3D space to position the + line. + zdir : {'x', 'y', 'z'} + Plane to plot line orthogonal to. Default: 'z'. + See `.get_dir_vector` for a description of the values. + axlim_clip : bool, default: False + Whether to hide lines with an endpoint outside the axes view limits. + """ + xs = self.get_xdata() + ys = self.get_ydata() + zs = cbook._to_unmasked_float_array(zs).ravel() + zs = np.broadcast_to(zs, len(xs)) + self._verts3d = juggle_axes(xs, ys, zs, zdir) + self._axlim_clip = axlim_clip + self.stale = True + + def set_data_3d(self, *args): + """ + Set the x, y and z data + + Parameters + ---------- + x : array-like + The x-data to be plotted. + y : array-like + The y-data to be plotted. + z : array-like + The z-data to be plotted. + + Notes + ----- + Accepts x, y, z arguments or a single array-like (x, y, z) + """ + if len(args) == 1: + args = args[0] + for name, xyz in zip('xyz', args): + if not np.iterable(xyz): + raise RuntimeError(f'{name} must be a sequence') + self._verts3d = args + self.stale = True + + def get_data_3d(self): + """ + Get the current data + + Returns + ------- + verts3d : length-3 tuple or array-like + The current data as a tuple or array-like. + """ + return self._verts3d + + @artist.allow_rasterization + def draw(self, renderer): + if self._axlim_clip: + xs3d, ys3d, zs3d = _viewlim_mask(*self._verts3d, self.axes) + else: + xs3d, ys3d, zs3d = self._verts3d + xs, ys, zs, tis = proj3d._proj_transform_clip(xs3d, ys3d, zs3d, + self.axes.M, + self.axes._focal_length) + self.set_data(xs, ys) + super().draw(renderer) + self.stale = False + + +def line_2d_to_3d(line, zs=0, zdir='z', axlim_clip=False): + """ + Convert a `.Line2D` to a `.Line3D` object. + + Parameters + ---------- + zs : float + The location along the *zdir* axis in 3D space to position the line. + zdir : {'x', 'y', 'z'} + Plane to plot line orthogonal to. Default: 'z'. + See `.get_dir_vector` for a description of the values. + axlim_clip : bool, default: False + Whether to hide lines with an endpoint outside the axes view limits. + """ + + line.__class__ = Line3D + line.set_3d_properties(zs, zdir, axlim_clip) + + +def _path_to_3d_segment(path, zs=0, zdir='z'): + """Convert a path to a 3D segment.""" + + zs = np.broadcast_to(zs, len(path)) + pathsegs = path.iter_segments(simplify=False, curves=False) + seg = [(x, y, z) for (((x, y), code), z) in zip(pathsegs, zs)] + seg3d = [juggle_axes(x, y, z, zdir) for (x, y, z) in seg] + return seg3d + + +def _paths_to_3d_segments(paths, zs=0, zdir='z'): + """Convert paths from a collection object to 3D segments.""" + + if not np.iterable(zs): + zs = np.broadcast_to(zs, len(paths)) + else: + if len(zs) != len(paths): + raise ValueError('Number of z-coordinates does not match paths.') + + segs = [_path_to_3d_segment(path, pathz, zdir) + for path, pathz in zip(paths, zs)] + return segs + + +def _path_to_3d_segment_with_codes(path, zs=0, zdir='z'): + """Convert a path to a 3D segment with path codes.""" + + zs = np.broadcast_to(zs, len(path)) + pathsegs = path.iter_segments(simplify=False, curves=False) + seg_codes = [((x, y, z), code) for ((x, y), code), z in zip(pathsegs, zs)] + if seg_codes: + seg, codes = zip(*seg_codes) + seg3d = [juggle_axes(x, y, z, zdir) for (x, y, z) in seg] + else: + seg3d = [] + codes = [] + return seg3d, list(codes) + + +def _paths_to_3d_segments_with_codes(paths, zs=0, zdir='z'): + """ + Convert paths from a collection object to 3D segments with path codes. + """ + + zs = np.broadcast_to(zs, len(paths)) + segments_codes = [_path_to_3d_segment_with_codes(path, pathz, zdir) + for path, pathz in zip(paths, zs)] + if segments_codes: + segments, codes = zip(*segments_codes) + else: + segments, codes = [], [] + return list(segments), list(codes) + + +class Collection3D(Collection): + """A collection of 3D paths.""" + + def do_3d_projection(self): + """Project the points according to renderer matrix.""" + vs_list = [vs for vs, _ in self._3dverts_codes] + if self._axlim_clip: + vs_list = [np.ma.row_stack(_viewlim_mask(*vs.T, self.axes)).T + for vs in vs_list] + xyzs_list = [proj3d.proj_transform(*vs.T, self.axes.M) for vs in vs_list] + self._paths = [mpath.Path(np.ma.column_stack([xs, ys]), cs) + for (xs, ys, _), (_, cs) in zip(xyzs_list, self._3dverts_codes)] + zs = np.concatenate([zs for _, _, zs in xyzs_list]) + return zs.min() if len(zs) else 1e9 + + +def collection_2d_to_3d(col, zs=0, zdir='z', axlim_clip=False): + """Convert a `.Collection` to a `.Collection3D` object.""" + zs = np.broadcast_to(zs, len(col.get_paths())) + col._3dverts_codes = [ + (np.column_stack(juggle_axes( + *np.column_stack([p.vertices, np.broadcast_to(z, len(p.vertices))]).T, + zdir)), + p.codes) + for p, z in zip(col.get_paths(), zs)] + col.__class__ = cbook._make_class_factory(Collection3D, "{}3D")(type(col)) + col._axlim_clip = axlim_clip + + +class Line3DCollection(LineCollection): + """ + A collection of 3D lines. + """ + def __init__(self, lines, axlim_clip=False, **kwargs): + super().__init__(lines, **kwargs) + self._axlim_clip = axlim_clip + + def set_sort_zpos(self, val): + """Set the position to use for z-sorting.""" + self._sort_zpos = val + self.stale = True + + def set_segments(self, segments): + """ + Set 3D segments. + """ + self._segments3d = segments + super().set_segments([]) + + def do_3d_projection(self): + """ + Project the points according to renderer matrix. + """ + segments = self._segments3d + if self._axlim_clip: + all_points = np.ma.vstack(segments) + masked_points = np.ma.column_stack([*_viewlim_mask(*all_points.T, + self.axes)]) + segment_lengths = [np.shape(segment)[0] for segment in segments] + segments = np.split(masked_points, np.cumsum(segment_lengths[:-1])) + xyslist = [proj3d._proj_trans_points(points, self.axes.M) + for points in segments] + segments_2d = [np.ma.column_stack([xs, ys]) for xs, ys, zs in xyslist] + LineCollection.set_segments(self, segments_2d) + + # FIXME + minz = 1e9 + for xs, ys, zs in xyslist: + minz = min(minz, min(zs)) + return minz + + +def line_collection_2d_to_3d(col, zs=0, zdir='z', axlim_clip=False): + """Convert a `.LineCollection` to a `.Line3DCollection` object.""" + segments3d = _paths_to_3d_segments(col.get_paths(), zs, zdir) + col.__class__ = Line3DCollection + col.set_segments(segments3d) + col._axlim_clip = axlim_clip + + +class Patch3D(Patch): + """ + 3D patch object. + """ + + def __init__(self, *args, zs=(), zdir='z', axlim_clip=False, **kwargs): + """ + Parameters + ---------- + verts : + zs : float + The location along the *zdir* axis in 3D space to position the + patch. + zdir : {'x', 'y', 'z'} + Plane to plot patch orthogonal to. Default: 'z'. + See `.get_dir_vector` for a description of the values. + axlim_clip : bool, default: False + Whether to hide patches with a vertex outside the axes view limits. + """ + super().__init__(*args, **kwargs) + self.set_3d_properties(zs, zdir, axlim_clip) + + def set_3d_properties(self, verts, zs=0, zdir='z', axlim_clip=False): + """ + Set the *z* position and direction of the patch. + + Parameters + ---------- + verts : + zs : float + The location along the *zdir* axis in 3D space to position the + patch. + zdir : {'x', 'y', 'z'} + Plane to plot patch orthogonal to. Default: 'z'. + See `.get_dir_vector` for a description of the values. + axlim_clip : bool, default: False + Whether to hide patches with a vertex outside the axes view limits. + """ + zs = np.broadcast_to(zs, len(verts)) + self._segment3d = [juggle_axes(x, y, z, zdir) + for ((x, y), z) in zip(verts, zs)] + self._axlim_clip = axlim_clip + + def get_path(self): + # docstring inherited + # self._path2d is not initialized until do_3d_projection + if not hasattr(self, '_path2d'): + self.axes.M = self.axes.get_proj() + self.do_3d_projection() + return self._path2d + + def do_3d_projection(self): + s = self._segment3d + if self._axlim_clip: + xs, ys, zs = _viewlim_mask(*zip(*s), self.axes) + else: + xs, ys, zs = zip(*s) + vxs, vys, vzs, vis = proj3d._proj_transform_clip(xs, ys, zs, + self.axes.M, + self.axes._focal_length) + self._path2d = mpath.Path(np.ma.column_stack([vxs, vys])) + return min(vzs) + + +class PathPatch3D(Patch3D): + """ + 3D PathPatch object. + """ + + def __init__(self, path, *, zs=(), zdir='z', axlim_clip=False, **kwargs): + """ + Parameters + ---------- + path : + zs : float + The location along the *zdir* axis in 3D space to position the + path patch. + zdir : {'x', 'y', 'z', 3-tuple} + Plane to plot path patch orthogonal to. Default: 'z'. + See `.get_dir_vector` for a description of the values. + axlim_clip : bool, default: False + Whether to hide path patches with a point outside the axes view limits. + """ + # Not super().__init__! + Patch.__init__(self, **kwargs) + self.set_3d_properties(path, zs, zdir, axlim_clip) + + def set_3d_properties(self, path, zs=0, zdir='z', axlim_clip=False): + """ + Set the *z* position and direction of the path patch. + + Parameters + ---------- + path : + zs : float + The location along the *zdir* axis in 3D space to position the + path patch. + zdir : {'x', 'y', 'z', 3-tuple} + Plane to plot path patch orthogonal to. Default: 'z'. + See `.get_dir_vector` for a description of the values. + axlim_clip : bool, default: False + Whether to hide path patches with a point outside the axes view limits. + """ + Patch3D.set_3d_properties(self, path.vertices, zs=zs, zdir=zdir, + axlim_clip=axlim_clip) + self._code3d = path.codes + + def do_3d_projection(self): + s = self._segment3d + if self._axlim_clip: + xs, ys, zs = _viewlim_mask(*zip(*s), self.axes) + else: + xs, ys, zs = zip(*s) + vxs, vys, vzs, vis = proj3d._proj_transform_clip(xs, ys, zs, + self.axes.M, + self.axes._focal_length) + self._path2d = mpath.Path(np.ma.column_stack([vxs, vys]), self._code3d) + return min(vzs) + + +def _get_patch_verts(patch): + """Return a list of vertices for the path of a patch.""" + trans = patch.get_patch_transform() + path = patch.get_path() + polygons = path.to_polygons(trans) + return polygons[0] if len(polygons) else np.array([]) + + +def patch_2d_to_3d(patch, z=0, zdir='z', axlim_clip=False): + """Convert a `.Patch` to a `.Patch3D` object.""" + verts = _get_patch_verts(patch) + patch.__class__ = Patch3D + patch.set_3d_properties(verts, z, zdir, axlim_clip) + + +def pathpatch_2d_to_3d(pathpatch, z=0, zdir='z'): + """Convert a `.PathPatch` to a `.PathPatch3D` object.""" + path = pathpatch.get_path() + trans = pathpatch.get_patch_transform() + + mpath = trans.transform_path(path) + pathpatch.__class__ = PathPatch3D + pathpatch.set_3d_properties(mpath, z, zdir) + + +class Patch3DCollection(PatchCollection): + """ + A collection of 3D patches. + """ + + def __init__(self, *args, + zs=0, zdir='z', depthshade=True, axlim_clip=False, **kwargs): + """ + Create a collection of flat 3D patches with its normal vector + pointed in *zdir* direction, and located at *zs* on the *zdir* + axis. 'zs' can be a scalar or an array-like of the same length as + the number of patches in the collection. + + Constructor arguments are the same as for + :class:`~matplotlib.collections.PatchCollection`. In addition, + keywords *zs=0* and *zdir='z'* are available. + + Also, the keyword argument *depthshade* is available to indicate + whether to shade the patches in order to give the appearance of depth + (default is *True*). This is typically desired in scatter plots. + """ + self._depthshade = depthshade + super().__init__(*args, **kwargs) + self.set_3d_properties(zs, zdir, axlim_clip) + + def get_depthshade(self): + return self._depthshade + + def set_depthshade(self, depthshade): + """ + Set whether depth shading is performed on collection members. + + Parameters + ---------- + depthshade : bool + Whether to shade the patches in order to give the appearance of + depth. + """ + self._depthshade = depthshade + self.stale = True + + def set_sort_zpos(self, val): + """Set the position to use for z-sorting.""" + self._sort_zpos = val + self.stale = True + + def set_3d_properties(self, zs, zdir, axlim_clip=False): + """ + Set the *z* positions and direction of the patches. + + Parameters + ---------- + zs : float or array of floats + The location or locations to place the patches in the collection + along the *zdir* axis. + zdir : {'x', 'y', 'z'} + Plane to plot patches orthogonal to. + All patches must have the same direction. + See `.get_dir_vector` for a description of the values. + axlim_clip : bool, default: False + Whether to hide patches with a vertex outside the axes view limits. + """ + # Force the collection to initialize the face and edgecolors + # just in case it is a scalarmappable with a colormap. + self.update_scalarmappable() + offsets = self.get_offsets() + if len(offsets) > 0: + xs, ys = offsets.T + else: + xs = [] + ys = [] + self._offsets3d = juggle_axes(xs, ys, np.atleast_1d(zs), zdir) + self._z_markers_idx = slice(-1) + self._vzs = None + self._axlim_clip = axlim_clip + self.stale = True + + def do_3d_projection(self): + if self._axlim_clip: + xs, ys, zs = _viewlim_mask(*self._offsets3d, self.axes) + else: + xs, ys, zs = self._offsets3d + vxs, vys, vzs, vis = proj3d._proj_transform_clip(xs, ys, zs, + self.axes.M, + self.axes._focal_length) + self._vzs = vzs + super().set_offsets(np.ma.column_stack([vxs, vys])) + + if vzs.size > 0: + return min(vzs) + else: + return np.nan + + def _maybe_depth_shade_and_sort_colors(self, color_array): + color_array = ( + _zalpha(color_array, self._vzs) + if self._vzs is not None and self._depthshade + else color_array + ) + if len(color_array) > 1: + color_array = color_array[self._z_markers_idx] + return mcolors.to_rgba_array(color_array, self._alpha) + + def get_facecolor(self): + return self._maybe_depth_shade_and_sort_colors(super().get_facecolor()) + + def get_edgecolor(self): + # We need this check here to make sure we do not double-apply the depth + # based alpha shading when the edge color is "face" which means the + # edge colour should be identical to the face colour. + if cbook._str_equal(self._edgecolors, 'face'): + return self.get_facecolor() + return self._maybe_depth_shade_and_sort_colors(super().get_edgecolor()) + + +class Path3DCollection(PathCollection): + """ + A collection of 3D paths. + """ + + def __init__(self, *args, + zs=0, zdir='z', depthshade=True, axlim_clip=False, **kwargs): + """ + Create a collection of flat 3D paths with its normal vector + pointed in *zdir* direction, and located at *zs* on the *zdir* + axis. 'zs' can be a scalar or an array-like of the same length as + the number of paths in the collection. + + Constructor arguments are the same as for + :class:`~matplotlib.collections.PathCollection`. In addition, + keywords *zs=0* and *zdir='z'* are available. + + Also, the keyword argument *depthshade* is available to indicate + whether to shade the patches in order to give the appearance of depth + (default is *True*). This is typically desired in scatter plots. + """ + self._depthshade = depthshade + self._in_draw = False + super().__init__(*args, **kwargs) + self.set_3d_properties(zs, zdir, axlim_clip) + self._offset_zordered = None + + def draw(self, renderer): + with self._use_zordered_offset(): + with cbook._setattr_cm(self, _in_draw=True): + super().draw(renderer) + + def set_sort_zpos(self, val): + """Set the position to use for z-sorting.""" + self._sort_zpos = val + self.stale = True + + def set_3d_properties(self, zs, zdir, axlim_clip=False): + """ + Set the *z* positions and direction of the paths. + + Parameters + ---------- + zs : float or array of floats + The location or locations to place the paths in the collection + along the *zdir* axis. + zdir : {'x', 'y', 'z'} + Plane to plot paths orthogonal to. + All paths must have the same direction. + See `.get_dir_vector` for a description of the values. + axlim_clip : bool, default: False + Whether to hide paths with a vertex outside the axes view limits. + """ + # Force the collection to initialize the face and edgecolors + # just in case it is a scalarmappable with a colormap. + self.update_scalarmappable() + offsets = self.get_offsets() + if len(offsets) > 0: + xs, ys = offsets.T + else: + xs = [] + ys = [] + self._zdir = zdir + self._offsets3d = juggle_axes(xs, ys, np.atleast_1d(zs), zdir) + # In the base draw methods we access the attributes directly which + # means we cannot resolve the shuffling in the getter methods like + # we do for the edge and face colors. + # + # This means we need to carry around a cache of the unsorted sizes and + # widths (postfixed with 3d) and in `do_3d_projection` set the + # depth-sorted version of that data into the private state used by the + # base collection class in its draw method. + # + # Grab the current sizes and linewidths to preserve them. + self._sizes3d = self._sizes + self._linewidths3d = np.array(self._linewidths) + xs, ys, zs = self._offsets3d + + # Sort the points based on z coordinates + # Performance optimization: Create a sorted index array and reorder + # points and point properties according to the index array + self._z_markers_idx = slice(-1) + self._vzs = None + + self._axlim_clip = axlim_clip + self.stale = True + + def set_sizes(self, sizes, dpi=72.0): + super().set_sizes(sizes, dpi) + if not self._in_draw: + self._sizes3d = sizes + + def set_linewidth(self, lw): + super().set_linewidth(lw) + if not self._in_draw: + self._linewidths3d = np.array(self._linewidths) + + def get_depthshade(self): + return self._depthshade + + def set_depthshade(self, depthshade): + """ + Set whether depth shading is performed on collection members. + + Parameters + ---------- + depthshade : bool + Whether to shade the patches in order to give the appearance of + depth. + """ + self._depthshade = depthshade + self.stale = True + + def do_3d_projection(self): + if self._axlim_clip: + xs, ys, zs = _viewlim_mask(*self._offsets3d, self.axes) + else: + xs, ys, zs = self._offsets3d + vxs, vys, vzs, vis = proj3d._proj_transform_clip(xs, ys, zs, + self.axes.M, + self.axes._focal_length) + # Sort the points based on z coordinates + # Performance optimization: Create a sorted index array and reorder + # points and point properties according to the index array + z_markers_idx = self._z_markers_idx = np.ma.argsort(vzs)[::-1] + self._vzs = vzs + + # we have to special case the sizes because of code in collections.py + # as the draw method does + # self.set_sizes(self._sizes, self.figure.dpi) + # so we cannot rely on doing the sorting on the way out via get_* + + if len(self._sizes3d) > 1: + self._sizes = self._sizes3d[z_markers_idx] + + if len(self._linewidths3d) > 1: + self._linewidths = self._linewidths3d[z_markers_idx] + + PathCollection.set_offsets(self, np.ma.column_stack((vxs, vys))) + + # Re-order items + vzs = vzs[z_markers_idx] + vxs = vxs[z_markers_idx] + vys = vys[z_markers_idx] + + # Store ordered offset for drawing purpose + self._offset_zordered = np.ma.column_stack((vxs, vys)) + + return np.min(vzs) if vzs.size else np.nan + + @contextmanager + def _use_zordered_offset(self): + if self._offset_zordered is None: + # Do nothing + yield + else: + # Swap offset with z-ordered offset + old_offset = self._offsets + super().set_offsets(self._offset_zordered) + try: + yield + finally: + self._offsets = old_offset + + def _maybe_depth_shade_and_sort_colors(self, color_array): + color_array = ( + _zalpha(color_array, self._vzs) + if self._vzs is not None and self._depthshade + else color_array + ) + if len(color_array) > 1: + color_array = color_array[self._z_markers_idx] + return mcolors.to_rgba_array(color_array, self._alpha) + + def get_facecolor(self): + return self._maybe_depth_shade_and_sort_colors(super().get_facecolor()) + + def get_edgecolor(self): + # We need this check here to make sure we do not double-apply the depth + # based alpha shading when the edge color is "face" which means the + # edge colour should be identical to the face colour. + if cbook._str_equal(self._edgecolors, 'face'): + return self.get_facecolor() + return self._maybe_depth_shade_and_sort_colors(super().get_edgecolor()) + + +def patch_collection_2d_to_3d(col, zs=0, zdir='z', depthshade=True, axlim_clip=False): + """ + Convert a `.PatchCollection` into a `.Patch3DCollection` object + (or a `.PathCollection` into a `.Path3DCollection` object). + + Parameters + ---------- + col : `~matplotlib.collections.PatchCollection` or \ +`~matplotlib.collections.PathCollection` + The collection to convert. + zs : float or array of floats + The location or locations to place the patches in the collection along + the *zdir* axis. Default: 0. + zdir : {'x', 'y', 'z'} + The axis in which to place the patches. Default: "z". + See `.get_dir_vector` for a description of the values. + depthshade : bool, default: True + Whether to shade the patches to give a sense of depth. + axlim_clip : bool, default: False + Whether to hide patches with a vertex outside the axes view limits. + """ + if isinstance(col, PathCollection): + col.__class__ = Path3DCollection + col._offset_zordered = None + elif isinstance(col, PatchCollection): + col.__class__ = Patch3DCollection + col._depthshade = depthshade + col._in_draw = False + col.set_3d_properties(zs, zdir, axlim_clip) + + +class Poly3DCollection(PolyCollection): + """ + A collection of 3D polygons. + + .. note:: + **Filling of 3D polygons** + + There is no simple definition of the enclosed surface of a 3D polygon + unless the polygon is planar. + + In practice, Matplotlib fills the 2D projection of the polygon. This + gives a correct filling appearance only for planar polygons. For all + other polygons, you'll find orientations in which the edges of the + polygon intersect in the projection. This will lead to an incorrect + visualization of the 3D area. + + If you need filled areas, it is recommended to create them via + `~mpl_toolkits.mplot3d.axes3d.Axes3D.plot_trisurf`, which creates a + triangulation and thus generates consistent surfaces. + """ + + def __init__(self, verts, *args, zsort='average', shade=False, + lightsource=None, axlim_clip=False, **kwargs): + """ + Parameters + ---------- + verts : list of (N, 3) array-like + The sequence of polygons [*verts0*, *verts1*, ...] where each + element *verts_i* defines the vertices of polygon *i* as a 2D + array-like of shape (N, 3). + zsort : {'average', 'min', 'max'}, default: 'average' + The calculation method for the z-order. + See `~.Poly3DCollection.set_zsort` for details. + shade : bool, default: False + Whether to shade *facecolors* and *edgecolors*. When activating + *shade*, *facecolors* and/or *edgecolors* must be provided. + + .. versionadded:: 3.7 + + lightsource : `~matplotlib.colors.LightSource`, optional + The lightsource to use when *shade* is True. + + .. versionadded:: 3.7 + + axlim_clip : bool, default: False + Whether to hide polygons with a vertex outside the view limits. + + *args, **kwargs + All other parameters are forwarded to `.PolyCollection`. + + Notes + ----- + Note that this class does a bit of magic with the _facecolors + and _edgecolors properties. + """ + if shade: + normals = _generate_normals(verts) + facecolors = kwargs.get('facecolors', None) + if facecolors is not None: + kwargs['facecolors'] = _shade_colors( + facecolors, normals, lightsource + ) + + edgecolors = kwargs.get('edgecolors', None) + if edgecolors is not None: + kwargs['edgecolors'] = _shade_colors( + edgecolors, normals, lightsource + ) + if facecolors is None and edgecolors is None: + raise ValueError( + "You must provide facecolors, edgecolors, or both for " + "shade to work.") + super().__init__(verts, *args, **kwargs) + if isinstance(verts, np.ndarray): + if verts.ndim != 3: + raise ValueError('verts must be a list of (N, 3) array-like') + else: + if any(len(np.shape(vert)) != 2 for vert in verts): + raise ValueError('verts must be a list of (N, 3) array-like') + self.set_zsort(zsort) + self._codes3d = None + self._axlim_clip = axlim_clip + + _zsort_functions = { + 'average': np.average, + 'min': np.min, + 'max': np.max, + } + + def set_zsort(self, zsort): + """ + Set the calculation method for the z-order. + + Parameters + ---------- + zsort : {'average', 'min', 'max'} + The function applied on the z-coordinates of the vertices in the + viewer's coordinate system, to determine the z-order. + """ + self._zsortfunc = self._zsort_functions[zsort] + self._sort_zpos = None + self.stale = True + + @_api.deprecated("3.10") + def get_vector(self, segments3d): + return self._get_vector(segments3d) + + def _get_vector(self, segments3d): + """Optimize points for projection.""" + if len(segments3d): + xs, ys, zs = np.vstack(segments3d).T + else: # vstack can't stack zero arrays. + xs, ys, zs = [], [], [] + ones = np.ones(len(xs)) + self._vec = np.array([xs, ys, zs, ones]) + + indices = [0, *np.cumsum([len(segment) for segment in segments3d])] + self._segslices = [*map(slice, indices[:-1], indices[1:])] + + def set_verts(self, verts, closed=True): + """ + Set 3D vertices. + + Parameters + ---------- + verts : list of (N, 3) array-like + The sequence of polygons [*verts0*, *verts1*, ...] where each + element *verts_i* defines the vertices of polygon *i* as a 2D + array-like of shape (N, 3). + closed : bool, default: True + Whether the polygon should be closed by adding a CLOSEPOLY + connection at the end. + """ + self._get_vector(verts) + # 2D verts will be updated at draw time + super().set_verts([], False) + self._closed = closed + + def set_verts_and_codes(self, verts, codes): + """Set 3D vertices with path codes.""" + # set vertices with closed=False to prevent PolyCollection from + # setting path codes + self.set_verts(verts, closed=False) + # and set our own codes instead. + self._codes3d = codes + + def set_3d_properties(self, axlim_clip=False): + # Force the collection to initialize the face and edgecolors + # just in case it is a scalarmappable with a colormap. + self.update_scalarmappable() + self._sort_zpos = None + self.set_zsort('average') + self._facecolor3d = PolyCollection.get_facecolor(self) + self._edgecolor3d = PolyCollection.get_edgecolor(self) + self._alpha3d = PolyCollection.get_alpha(self) + self.stale = True + + def set_sort_zpos(self, val): + """Set the position to use for z-sorting.""" + self._sort_zpos = val + self.stale = True + + def do_3d_projection(self): + """ + Perform the 3D projection for this object. + """ + if self._A is not None: + # force update of color mapping because we re-order them + # below. If we do not do this here, the 2D draw will call + # this, but we will never port the color mapped values back + # to the 3D versions. + # + # We hold the 3D versions in a fixed order (the order the user + # passed in) and sort the 2D version by view depth. + self.update_scalarmappable() + if self._face_is_mapped: + self._facecolor3d = self._facecolors + if self._edge_is_mapped: + self._edgecolor3d = self._edgecolors + if self._axlim_clip: + xs, ys, zs = _viewlim_mask(*self._vec[0:3], self.axes) + if self._vec.shape[0] == 4: # Will be 3 (xyz) or 4 (xyzw) + w_masked = np.ma.masked_where(zs.mask, self._vec[3]) + vec = np.ma.array([xs, ys, zs, w_masked]) + else: + vec = np.ma.array([xs, ys, zs]) + else: + vec = self._vec + txs, tys, tzs = proj3d._proj_transform_vec(vec, self.axes.M) + xyzlist = [(txs[sl], tys[sl], tzs[sl]) for sl in self._segslices] + + # This extra fuss is to re-order face / edge colors + cface = self._facecolor3d + cedge = self._edgecolor3d + if len(cface) != len(xyzlist): + cface = cface.repeat(len(xyzlist), axis=0) + if len(cedge) != len(xyzlist): + if len(cedge) == 0: + cedge = cface + else: + cedge = cedge.repeat(len(xyzlist), axis=0) + + if xyzlist: + # sort by depth (furthest drawn first) + z_segments_2d = sorted( + ((self._zsortfunc(zs.data), np.ma.column_stack([xs, ys]), fc, ec, idx) + for idx, ((xs, ys, zs), fc, ec) + in enumerate(zip(xyzlist, cface, cedge))), + key=lambda x: x[0], reverse=True) + + _, segments_2d, self._facecolors2d, self._edgecolors2d, idxs = \ + zip(*z_segments_2d) + else: + segments_2d = [] + self._facecolors2d = np.empty((0, 4)) + self._edgecolors2d = np.empty((0, 4)) + idxs = [] + + if self._codes3d is not None: + codes = [self._codes3d[idx] for idx in idxs] + PolyCollection.set_verts_and_codes(self, segments_2d, codes) + else: + PolyCollection.set_verts(self, segments_2d, self._closed) + + if len(self._edgecolor3d) != len(cface): + self._edgecolors2d = self._edgecolor3d + + # Return zorder value + if self._sort_zpos is not None: + zvec = np.array([[0], [0], [self._sort_zpos], [1]]) + ztrans = proj3d._proj_transform_vec(zvec, self.axes.M) + return ztrans[2][0] + elif tzs.size > 0: + # FIXME: Some results still don't look quite right. + # In particular, examine contourf3d_demo2.py + # with az = -54 and elev = -45. + return np.min(tzs) + else: + return np.nan + + def set_facecolor(self, colors): + # docstring inherited + super().set_facecolor(colors) + self._facecolor3d = PolyCollection.get_facecolor(self) + + def set_edgecolor(self, colors): + # docstring inherited + super().set_edgecolor(colors) + self._edgecolor3d = PolyCollection.get_edgecolor(self) + + def set_alpha(self, alpha): + # docstring inherited + artist.Artist.set_alpha(self, alpha) + try: + self._facecolor3d = mcolors.to_rgba_array( + self._facecolor3d, self._alpha) + except (AttributeError, TypeError, IndexError): + pass + try: + self._edgecolors = mcolors.to_rgba_array( + self._edgecolor3d, self._alpha) + except (AttributeError, TypeError, IndexError): + pass + self.stale = True + + def get_facecolor(self): + # docstring inherited + # self._facecolors2d is not initialized until do_3d_projection + if not hasattr(self, '_facecolors2d'): + self.axes.M = self.axes.get_proj() + self.do_3d_projection() + return np.asarray(self._facecolors2d) + + def get_edgecolor(self): + # docstring inherited + # self._edgecolors2d is not initialized until do_3d_projection + if not hasattr(self, '_edgecolors2d'): + self.axes.M = self.axes.get_proj() + self.do_3d_projection() + return np.asarray(self._edgecolors2d) + + +def poly_collection_2d_to_3d(col, zs=0, zdir='z', axlim_clip=False): + """ + Convert a `.PolyCollection` into a `.Poly3DCollection` object. + + Parameters + ---------- + col : `~matplotlib.collections.PolyCollection` + The collection to convert. + zs : float or array of floats + The location or locations to place the polygons in the collection along + the *zdir* axis. Default: 0. + zdir : {'x', 'y', 'z'} + The axis in which to place the patches. Default: 'z'. + See `.get_dir_vector` for a description of the values. + """ + segments_3d, codes = _paths_to_3d_segments_with_codes( + col.get_paths(), zs, zdir) + col.__class__ = Poly3DCollection + col.set_verts_and_codes(segments_3d, codes) + col.set_3d_properties() + col._axlim_clip = axlim_clip + + +def juggle_axes(xs, ys, zs, zdir): + """ + Reorder coordinates so that 2D *xs*, *ys* can be plotted in the plane + orthogonal to *zdir*. *zdir* is normally 'x', 'y' or 'z'. However, if + *zdir* starts with a '-' it is interpreted as a compensation for + `rotate_axes`. + """ + if zdir == 'x': + return zs, xs, ys + elif zdir == 'y': + return xs, zs, ys + elif zdir[0] == '-': + return rotate_axes(xs, ys, zs, zdir) + else: + return xs, ys, zs + + +def rotate_axes(xs, ys, zs, zdir): + """ + Reorder coordinates so that the axes are rotated with *zdir* along + the original z axis. Prepending the axis with a '-' does the + inverse transform, so *zdir* can be 'x', '-x', 'y', '-y', 'z' or '-z'. + """ + if zdir in ('x', '-y'): + return ys, zs, xs + elif zdir in ('-x', 'y'): + return zs, xs, ys + else: + return xs, ys, zs + + +def _zalpha(colors, zs): + """Modify the alphas of the color list according to depth.""" + # FIXME: This only works well if the points for *zs* are well-spaced + # in all three dimensions. Otherwise, at certain orientations, + # the min and max zs are very close together. + # Should really normalize against the viewing depth. + if len(colors) == 0 or len(zs) == 0: + return np.zeros((0, 4)) + norm = Normalize(min(zs), max(zs)) + sats = 1 - norm(zs) * 0.7 + rgba = np.broadcast_to(mcolors.to_rgba_array(colors), (len(zs), 4)) + return np.column_stack([rgba[:, :3], rgba[:, 3] * sats]) + + +def _all_points_on_plane(xs, ys, zs, atol=1e-8): + """ + Check if all points are on the same plane. Note that NaN values are + ignored. + + Parameters + ---------- + xs, ys, zs : array-like + The x, y, and z coordinates of the points. + atol : float, default: 1e-8 + The tolerance for the equality check. + """ + xs, ys, zs = np.asarray(xs), np.asarray(ys), np.asarray(zs) + points = np.column_stack([xs, ys, zs]) + points = points[~np.isnan(points).any(axis=1)] + # Check for the case where we have less than 3 unique points + points = np.unique(points, axis=0) + if len(points) <= 3: + return True + # Calculate the vectors from the first point to all other points + vs = (points - points[0])[1:] + vs = vs / np.linalg.norm(vs, axis=1)[:, np.newaxis] + # Filter out parallel vectors + vs = np.unique(vs, axis=0) + if len(vs) <= 2: + return True + # Filter out parallel and antiparallel vectors to the first vector + cross_norms = np.linalg.norm(np.cross(vs[0], vs[1:]), axis=1) + zero_cross_norms = np.where(np.isclose(cross_norms, 0, atol=atol))[0] + 1 + vs = np.delete(vs, zero_cross_norms, axis=0) + if len(vs) <= 2: + return True + # Calculate the normal vector from the first three points + n = np.cross(vs[0], vs[1]) + n = n / np.linalg.norm(n) + # If the dot product of the normal vector and all other vectors is zero, + # all points are on the same plane + dots = np.dot(n, vs.transpose()) + return np.allclose(dots, 0, atol=atol) + + +def _generate_normals(polygons): + """ + Compute the normals of a list of polygons, one normal per polygon. + + Normals point towards the viewer for a face with its vertices in + counterclockwise order, following the right hand rule. + + Uses three points equally spaced around the polygon. This method assumes + that the points are in a plane. Otherwise, more than one shade is required, + which is not supported. + + Parameters + ---------- + polygons : list of (M_i, 3) array-like, or (..., M, 3) array-like + A sequence of polygons to compute normals for, which can have + varying numbers of vertices. If the polygons all have the same + number of vertices and array is passed, then the operation will + be vectorized. + + Returns + ------- + normals : (..., 3) array + A normal vector estimated for the polygon. + """ + if isinstance(polygons, np.ndarray): + # optimization: polygons all have the same number of points, so can + # vectorize + n = polygons.shape[-2] + i1, i2, i3 = 0, n//3, 2*n//3 + v1 = polygons[..., i1, :] - polygons[..., i2, :] + v2 = polygons[..., i2, :] - polygons[..., i3, :] + else: + # The subtraction doesn't vectorize because polygons is jagged. + v1 = np.empty((len(polygons), 3)) + v2 = np.empty((len(polygons), 3)) + for poly_i, ps in enumerate(polygons): + n = len(ps) + ps = np.asarray(ps) + i1, i2, i3 = 0, n//3, 2*n//3 + v1[poly_i, :] = ps[i1, :] - ps[i2, :] + v2[poly_i, :] = ps[i2, :] - ps[i3, :] + return np.cross(v1, v2) + + +def _shade_colors(color, normals, lightsource=None): + """ + Shade *color* using normal vectors given by *normals*, + assuming a *lightsource* (using default position if not given). + *color* can also be an array of the same length as *normals*. + """ + if lightsource is None: + # chosen for backwards-compatibility + lightsource = mcolors.LightSource(azdeg=225, altdeg=19.4712) + + with np.errstate(invalid="ignore"): + shade = ((normals / np.linalg.norm(normals, axis=1, keepdims=True)) + @ lightsource.direction) + mask = ~np.isnan(shade) + + if mask.any(): + # convert dot product to allowed shading fractions + in_norm = mcolors.Normalize(-1, 1) + out_norm = mcolors.Normalize(0.3, 1).inverse + + def norm(x): + return out_norm(in_norm(x)) + + shade[~mask] = 0 + + color = mcolors.to_rgba_array(color) + # shape of color should be (M, 4) (where M is number of faces) + # shape of shade should be (M,) + # colors should have final shape of (M, 4) + alpha = color[:, 3] + colors = norm(shade)[:, np.newaxis] * color + colors[:, 3] = alpha + else: + colors = np.asanyarray(color).copy() + + return colors diff --git a/lib/python3.12/site-packages/mpl_toolkits/mplot3d/axes3d.py b/lib/python3.12/site-packages/mpl_toolkits/mplot3d/axes3d.py new file mode 100644 index 0000000000000000000000000000000000000000..d0ba360c314bbaa39efd5e64936a07e6cd0ec0f6 --- /dev/null +++ b/lib/python3.12/site-packages/mpl_toolkits/mplot3d/axes3d.py @@ -0,0 +1,4162 @@ +""" +axes3d.py, original mplot3d version by John Porter +Created: 23 Sep 2005 + +Parts fixed by Reinier Heeres <reinier@heeres.eu> +Minor additions by Ben Axelrod <baxelrod@coroware.com> +Significant updates and revisions by Ben Root <ben.v.root@gmail.com> + +Module containing Axes3D, an object which can plot 3D objects on a +2D matplotlib figure. +""" + +from collections import defaultdict +import itertools +import math +import textwrap +import warnings + +import numpy as np + +import matplotlib as mpl +from matplotlib import _api, cbook, _docstring, _preprocess_data +import matplotlib.artist as martist +import matplotlib.collections as mcoll +import matplotlib.colors as mcolors +import matplotlib.image as mimage +import matplotlib.lines as mlines +import matplotlib.patches as mpatches +import matplotlib.container as mcontainer +import matplotlib.transforms as mtransforms +from matplotlib.axes import Axes +from matplotlib.axes._base import _axis_method_wrapper, _process_plot_format +from matplotlib.transforms import Bbox +from matplotlib.tri._triangulation import Triangulation + +from . import art3d +from . import proj3d +from . import axis3d + + +@_docstring.interpd +@_api.define_aliases({ + "xlim": ["xlim3d"], "ylim": ["ylim3d"], "zlim": ["zlim3d"]}) +class Axes3D(Axes): + """ + 3D Axes object. + + .. note:: + + As a user, you do not instantiate Axes directly, but use Axes creation + methods instead; e.g. from `.pyplot` or `.Figure`: + `~.pyplot.subplots`, `~.pyplot.subplot_mosaic` or `.Figure.add_axes`. + """ + name = '3d' + + _axis_names = ("x", "y", "z") + Axes._shared_axes["z"] = cbook.Grouper() + Axes._shared_axes["view"] = cbook.Grouper() + + def __init__( + self, fig, rect=None, *args, + elev=30, azim=-60, roll=0, shareview=None, sharez=None, + proj_type='persp', focal_length=None, + box_aspect=None, + computed_zorder=True, + **kwargs, + ): + """ + Parameters + ---------- + fig : Figure + The parent figure. + rect : tuple (left, bottom, width, height), default: None. + The ``(left, bottom, width, height)`` Axes position. + elev : float, default: 30 + The elevation angle in degrees rotates the camera above and below + the x-y plane, with a positive angle corresponding to a location + above the plane. + azim : float, default: -60 + The azimuthal angle in degrees rotates the camera about the z axis, + with a positive angle corresponding to a right-handed rotation. In + other words, a positive azimuth rotates the camera about the origin + from its location along the +x axis towards the +y axis. + roll : float, default: 0 + The roll angle in degrees rotates the camera about the viewing + axis. A positive angle spins the camera clockwise, causing the + scene to rotate counter-clockwise. + shareview : Axes3D, optional + Other Axes to share view angles with. Note that it is not possible + to unshare axes. + sharez : Axes3D, optional + Other Axes to share z-limits with. Note that it is not possible to + unshare axes. + proj_type : {'persp', 'ortho'} + The projection type, default 'persp'. + focal_length : float, default: None + For a projection type of 'persp', the focal length of the virtual + camera. Must be > 0. If None, defaults to 1. + For a projection type of 'ortho', must be set to either None + or infinity (numpy.inf). If None, defaults to infinity. + The focal length can be computed from a desired Field Of View via + the equation: focal_length = 1/tan(FOV/2) + box_aspect : 3-tuple of floats, default: None + Changes the physical dimensions of the Axes3D, such that the ratio + of the axis lengths in display units is x:y:z. + If None, defaults to 4:4:3 + computed_zorder : bool, default: True + If True, the draw order is computed based on the average position + of the `.Artist`\\s along the view direction. + Set to False if you want to manually control the order in which + Artists are drawn on top of each other using their *zorder* + attribute. This can be used for fine-tuning if the automatic order + does not produce the desired result. Note however, that a manual + zorder will only be correct for a limited view angle. If the figure + is rotated by the user, it will look wrong from certain angles. + + **kwargs + Other optional keyword arguments: + + %(Axes3D:kwdoc)s + """ + + if rect is None: + rect = [0.0, 0.0, 1.0, 1.0] + + self.initial_azim = azim + self.initial_elev = elev + self.initial_roll = roll + self.set_proj_type(proj_type, focal_length) + self.computed_zorder = computed_zorder + + self.xy_viewLim = Bbox.unit() + self.zz_viewLim = Bbox.unit() + xymargin = 0.05 * 10/11 # match mpl3.8 appearance + self.xy_dataLim = Bbox([[xymargin, xymargin], + [1 - xymargin, 1 - xymargin]]) + # z-limits are encoded in the x-component of the Bbox, y is un-used + self.zz_dataLim = Bbox.unit() + + # inhibit autoscale_view until the axes are defined + # they can't be defined until Axes.__init__ has been called + self.view_init(self.initial_elev, self.initial_azim, self.initial_roll) + + self._sharez = sharez + if sharez is not None: + self._shared_axes["z"].join(self, sharez) + self._adjustable = 'datalim' + + self._shareview = shareview + if shareview is not None: + self._shared_axes["view"].join(self, shareview) + + if kwargs.pop('auto_add_to_figure', False): + raise AttributeError( + 'auto_add_to_figure is no longer supported for Axes3D. ' + 'Use fig.add_axes(ax) instead.' + ) + + super().__init__( + fig, rect, frameon=True, box_aspect=box_aspect, *args, **kwargs + ) + # Disable drawing of axes by base class + super().set_axis_off() + # Enable drawing of axes by Axes3D class + self.set_axis_on() + self.M = None + self.invM = None + + self._view_margin = 1/48 # default value to match mpl3.8 + self.autoscale_view() + + # func used to format z -- fall back on major formatters + self.fmt_zdata = None + + self.mouse_init() + fig = self.get_figure(root=True) + fig.canvas.callbacks._connect_picklable( + 'motion_notify_event', self._on_move) + fig.canvas.callbacks._connect_picklable( + 'button_press_event', self._button_press) + fig.canvas.callbacks._connect_picklable( + 'button_release_event', self._button_release) + self.set_top_view() + + self.patch.set_linewidth(0) + # Calculate the pseudo-data width and height + pseudo_bbox = self.transLimits.inverted().transform([(0, 0), (1, 1)]) + self._pseudo_w, self._pseudo_h = pseudo_bbox[1] - pseudo_bbox[0] + + # mplot3d currently manages its own spines and needs these turned off + # for bounding box calculations + self.spines[:].set_visible(False) + + def set_axis_off(self): + self._axis3don = False + self.stale = True + + def set_axis_on(self): + self._axis3don = True + self.stale = True + + def convert_zunits(self, z): + """ + For artists in an Axes, if the zaxis has units support, + convert *z* using zaxis unit type + """ + return self.zaxis.convert_units(z) + + def set_top_view(self): + # this happens to be the right view for the viewing coordinates + # moved up and to the left slightly to fit labels and axes + xdwl = 0.95 / self._dist + xdw = 0.9 / self._dist + ydwl = 0.95 / self._dist + ydw = 0.9 / self._dist + # Set the viewing pane. + self.viewLim.intervalx = (-xdwl, xdw) + self.viewLim.intervaly = (-ydwl, ydw) + self.stale = True + + def _init_axis(self): + """Init 3D Axes; overrides creation of regular X/Y Axes.""" + self.xaxis = axis3d.XAxis(self) + self.yaxis = axis3d.YAxis(self) + self.zaxis = axis3d.ZAxis(self) + + def get_zaxis(self): + """Return the ``ZAxis`` (`~.axis3d.Axis`) instance.""" + return self.zaxis + + get_zgridlines = _axis_method_wrapper("zaxis", "get_gridlines") + get_zticklines = _axis_method_wrapper("zaxis", "get_ticklines") + + def _transformed_cube(self, vals): + """Return cube with limits from *vals* transformed by self.M.""" + minx, maxx, miny, maxy, minz, maxz = vals + xyzs = [(minx, miny, minz), + (maxx, miny, minz), + (maxx, maxy, minz), + (minx, maxy, minz), + (minx, miny, maxz), + (maxx, miny, maxz), + (maxx, maxy, maxz), + (minx, maxy, maxz)] + return proj3d._proj_points(xyzs, self.M) + + def set_aspect(self, aspect, adjustable=None, anchor=None, share=False): + """ + Set the aspect ratios. + + Parameters + ---------- + aspect : {'auto', 'equal', 'equalxy', 'equalxz', 'equalyz'} + Possible values: + + ========= ================================================== + value description + ========= ================================================== + 'auto' automatic; fill the position rectangle with data. + 'equal' adapt all the axes to have equal aspect ratios. + 'equalxy' adapt the x and y axes to have equal aspect ratios. + 'equalxz' adapt the x and z axes to have equal aspect ratios. + 'equalyz' adapt the y and z axes to have equal aspect ratios. + ========= ================================================== + + adjustable : None or {'box', 'datalim'}, optional + If not *None*, this defines which parameter will be adjusted to + meet the required aspect. See `.set_adjustable` for further + details. + + anchor : None or str or 2-tuple of float, optional + If not *None*, this defines where the Axes will be drawn if there + is extra space due to aspect constraints. The most common way to + specify the anchor are abbreviations of cardinal directions: + + ===== ===================== + value description + ===== ===================== + 'C' centered + 'SW' lower left corner + 'S' middle of bottom edge + 'SE' lower right corner + etc. + ===== ===================== + + See `~.Axes.set_anchor` for further details. + + share : bool, default: False + If ``True``, apply the settings to all shared Axes. + + See Also + -------- + mpl_toolkits.mplot3d.axes3d.Axes3D.set_box_aspect + """ + _api.check_in_list(('auto', 'equal', 'equalxy', 'equalyz', 'equalxz'), + aspect=aspect) + super().set_aspect( + aspect='auto', adjustable=adjustable, anchor=anchor, share=share) + self._aspect = aspect + + if aspect in ('equal', 'equalxy', 'equalxz', 'equalyz'): + ax_indices = self._equal_aspect_axis_indices(aspect) + + view_intervals = np.array([self.xaxis.get_view_interval(), + self.yaxis.get_view_interval(), + self.zaxis.get_view_interval()]) + ptp = np.ptp(view_intervals, axis=1) + if self._adjustable == 'datalim': + mean = np.mean(view_intervals, axis=1) + scale = max(ptp[ax_indices] / self._box_aspect[ax_indices]) + deltas = scale * self._box_aspect + + for i, set_lim in enumerate((self.set_xlim3d, + self.set_ylim3d, + self.set_zlim3d)): + if i in ax_indices: + set_lim(mean[i] - deltas[i]/2., mean[i] + deltas[i]/2., + auto=True, view_margin=None) + else: # 'box' + # Change the box aspect such that the ratio of the length of + # the unmodified axis to the length of the diagonal + # perpendicular to it remains unchanged. + box_aspect = np.array(self._box_aspect) + box_aspect[ax_indices] = ptp[ax_indices] + remaining_ax_indices = {0, 1, 2}.difference(ax_indices) + if remaining_ax_indices: + remaining = remaining_ax_indices.pop() + old_diag = np.linalg.norm(self._box_aspect[ax_indices]) + new_diag = np.linalg.norm(box_aspect[ax_indices]) + box_aspect[remaining] *= new_diag / old_diag + self.set_box_aspect(box_aspect) + + def _equal_aspect_axis_indices(self, aspect): + """ + Get the indices for which of the x, y, z axes are constrained to have + equal aspect ratios. + + Parameters + ---------- + aspect : {'auto', 'equal', 'equalxy', 'equalxz', 'equalyz'} + See descriptions in docstring for `.set_aspect()`. + """ + ax_indices = [] # aspect == 'auto' + if aspect == 'equal': + ax_indices = [0, 1, 2] + elif aspect == 'equalxy': + ax_indices = [0, 1] + elif aspect == 'equalxz': + ax_indices = [0, 2] + elif aspect == 'equalyz': + ax_indices = [1, 2] + return ax_indices + + def set_box_aspect(self, aspect, *, zoom=1): + """ + Set the Axes box aspect. + + The box aspect is the ratio of height to width in display + units for each face of the box when viewed perpendicular to + that face. This is not to be confused with the data aspect (see + `~.Axes3D.set_aspect`). The default ratios are 4:4:3 (x:y:z). + + To simulate having equal aspect in data space, set the box + aspect to match your data range in each dimension. + + *zoom* controls the overall size of the Axes3D in the figure. + + Parameters + ---------- + aspect : 3-tuple of floats or None + Changes the physical dimensions of the Axes3D, such that the ratio + of the axis lengths in display units is x:y:z. + If None, defaults to (4, 4, 3). + + zoom : float, default: 1 + Control overall size of the Axes3D in the figure. Must be > 0. + """ + if zoom <= 0: + raise ValueError(f'Argument zoom = {zoom} must be > 0') + + if aspect is None: + aspect = np.asarray((4, 4, 3), dtype=float) + else: + aspect = np.asarray(aspect, dtype=float) + _api.check_shape((3,), aspect=aspect) + # The scale 1.8294640721620434 is tuned to match the mpl3.2 appearance. + # The 25/24 factor is to compensate for the change in automargin + # behavior in mpl3.9. This comes from the padding of 1/48 on both sides + # of the axes in mpl3.8. + aspect *= 1.8294640721620434 * 25/24 * zoom / np.linalg.norm(aspect) + + self._box_aspect = self._roll_to_vertical(aspect, reverse=True) + self.stale = True + + def apply_aspect(self, position=None): + if position is None: + position = self.get_position(original=True) + + # in the superclass, we would go through and actually deal with axis + # scales and box/datalim. Those are all irrelevant - all we need to do + # is make sure our coordinate system is square. + trans = self.get_figure().transSubfigure + bb = mtransforms.Bbox.unit().transformed(trans) + # this is the physical aspect of the panel (or figure): + fig_aspect = bb.height / bb.width + + box_aspect = 1 + pb = position.frozen() + pb1 = pb.shrunk_to_aspect(box_aspect, pb, fig_aspect) + self._set_position(pb1.anchored(self.get_anchor(), pb), 'active') + + @martist.allow_rasterization + def draw(self, renderer): + if not self.get_visible(): + return + self._unstale_viewLim() + + # draw the background patch + self.patch.draw(renderer) + self._frameon = False + + # first, set the aspect + # this is duplicated from `axes._base._AxesBase.draw` + # but must be called before any of the artist are drawn as + # it adjusts the view limits and the size of the bounding box + # of the Axes + locator = self.get_axes_locator() + self.apply_aspect(locator(self, renderer) if locator else None) + + # add the projection matrix to the renderer + self.M = self.get_proj() + self.invM = np.linalg.inv(self.M) + + collections_and_patches = ( + artist for artist in self._children + if isinstance(artist, (mcoll.Collection, mpatches.Patch)) + and artist.get_visible()) + if self.computed_zorder: + # Calculate projection of collections and patches and zorder + # them. Make sure they are drawn above the grids. + zorder_offset = max(axis.get_zorder() + for axis in self._axis_map.values()) + 1 + collection_zorder = patch_zorder = zorder_offset + + for artist in sorted(collections_and_patches, + key=lambda artist: artist.do_3d_projection(), + reverse=True): + if isinstance(artist, mcoll.Collection): + artist.zorder = collection_zorder + collection_zorder += 1 + elif isinstance(artist, mpatches.Patch): + artist.zorder = patch_zorder + patch_zorder += 1 + else: + for artist in collections_and_patches: + artist.do_3d_projection() + + if self._axis3don: + # Draw panes first + for axis in self._axis_map.values(): + axis.draw_pane(renderer) + # Then gridlines + for axis in self._axis_map.values(): + axis.draw_grid(renderer) + # Then axes, labels, text, and ticks + for axis in self._axis_map.values(): + axis.draw(renderer) + + # Then rest + super().draw(renderer) + + def get_axis_position(self): + tc = self._transformed_cube(self.get_w_lims()) + xhigh = tc[1][2] > tc[2][2] + yhigh = tc[3][2] > tc[2][2] + zhigh = tc[0][2] > tc[2][2] + return xhigh, yhigh, zhigh + + def update_datalim(self, xys, **kwargs): + """ + Not implemented in `~mpl_toolkits.mplot3d.axes3d.Axes3D`. + """ + pass + + get_autoscalez_on = _axis_method_wrapper("zaxis", "_get_autoscale_on") + set_autoscalez_on = _axis_method_wrapper("zaxis", "_set_autoscale_on") + + def get_zmargin(self): + """ + Retrieve autoscaling margin of the z-axis. + + .. versionadded:: 3.9 + + Returns + ------- + zmargin : float + + See Also + -------- + mpl_toolkits.mplot3d.axes3d.Axes3D.set_zmargin + """ + return self._zmargin + + def set_zmargin(self, m): + """ + Set padding of Z data limits prior to autoscaling. + + *m* times the data interval will be added to each end of that interval + before it is used in autoscaling. If *m* is negative, this will clip + the data range instead of expanding it. + + For example, if your data is in the range [0, 2], a margin of 0.1 will + result in a range [-0.2, 2.2]; a margin of -0.1 will result in a range + of [0.2, 1.8]. + + Parameters + ---------- + m : float greater than -0.5 + """ + if m <= -0.5: + raise ValueError("margin must be greater than -0.5") + self._zmargin = m + self._request_autoscale_view("z") + self.stale = True + + def margins(self, *margins, x=None, y=None, z=None, tight=True): + """ + Set or retrieve autoscaling margins. + + See `.Axes.margins` for full documentation. Because this function + applies to 3D Axes, it also takes a *z* argument, and returns + ``(xmargin, ymargin, zmargin)``. + """ + if margins and (x is not None or y is not None or z is not None): + raise TypeError('Cannot pass both positional and keyword ' + 'arguments for x, y, and/or z.') + elif len(margins) == 1: + x = y = z = margins[0] + elif len(margins) == 3: + x, y, z = margins + elif margins: + raise TypeError('Must pass a single positional argument for all ' + 'margins, or one for each margin (x, y, z).') + + if x is None and y is None and z is None: + if tight is not True: + _api.warn_external(f'ignoring tight={tight!r} in get mode') + return self._xmargin, self._ymargin, self._zmargin + + if x is not None: + self.set_xmargin(x) + if y is not None: + self.set_ymargin(y) + if z is not None: + self.set_zmargin(z) + + self.autoscale_view( + tight=tight, scalex=(x is not None), scaley=(y is not None), + scalez=(z is not None) + ) + + def autoscale(self, enable=True, axis='both', tight=None): + """ + Convenience method for simple axis view autoscaling. + + See `.Axes.autoscale` for full documentation. Because this function + applies to 3D Axes, *axis* can also be set to 'z', and setting *axis* + to 'both' autoscales all three axes. + """ + if enable is None: + scalex = True + scaley = True + scalez = True + else: + if axis in ['x', 'both']: + self.set_autoscalex_on(enable) + scalex = self.get_autoscalex_on() + else: + scalex = False + if axis in ['y', 'both']: + self.set_autoscaley_on(enable) + scaley = self.get_autoscaley_on() + else: + scaley = False + if axis in ['z', 'both']: + self.set_autoscalez_on(enable) + scalez = self.get_autoscalez_on() + else: + scalez = False + if scalex: + self._request_autoscale_view("x", tight=tight) + if scaley: + self._request_autoscale_view("y", tight=tight) + if scalez: + self._request_autoscale_view("z", tight=tight) + + def auto_scale_xyz(self, X, Y, Z=None, had_data=None): + # This updates the bounding boxes as to keep a record as to what the + # minimum sized rectangular volume holds the data. + if np.shape(X) == np.shape(Y): + self.xy_dataLim.update_from_data_xy( + np.column_stack([np.ravel(X), np.ravel(Y)]), not had_data) + else: + self.xy_dataLim.update_from_data_x(X, not had_data) + self.xy_dataLim.update_from_data_y(Y, not had_data) + if Z is not None: + self.zz_dataLim.update_from_data_x(Z, not had_data) + # Let autoscale_view figure out how to use this data. + self.autoscale_view() + + def autoscale_view(self, tight=None, + scalex=True, scaley=True, scalez=True): + """ + Autoscale the view limits using the data limits. + + See `.Axes.autoscale_view` for full documentation. Because this + function applies to 3D Axes, it also takes a *scalez* argument. + """ + # This method looks at the rectangular volume (see above) + # of data and decides how to scale the view portal to fit it. + if tight is None: + _tight = self._tight + if not _tight: + # if image data only just use the datalim + for artist in self._children: + if isinstance(artist, mimage.AxesImage): + _tight = True + elif isinstance(artist, (mlines.Line2D, mpatches.Patch)): + _tight = False + break + else: + _tight = self._tight = bool(tight) + + if scalex and self.get_autoscalex_on(): + x0, x1 = self.xy_dataLim.intervalx + xlocator = self.xaxis.get_major_locator() + x0, x1 = xlocator.nonsingular(x0, x1) + if self._xmargin > 0: + delta = (x1 - x0) * self._xmargin + x0 -= delta + x1 += delta + if not _tight: + x0, x1 = xlocator.view_limits(x0, x1) + self.set_xbound(x0, x1, self._view_margin) + + if scaley and self.get_autoscaley_on(): + y0, y1 = self.xy_dataLim.intervaly + ylocator = self.yaxis.get_major_locator() + y0, y1 = ylocator.nonsingular(y0, y1) + if self._ymargin > 0: + delta = (y1 - y0) * self._ymargin + y0 -= delta + y1 += delta + if not _tight: + y0, y1 = ylocator.view_limits(y0, y1) + self.set_ybound(y0, y1, self._view_margin) + + if scalez and self.get_autoscalez_on(): + z0, z1 = self.zz_dataLim.intervalx + zlocator = self.zaxis.get_major_locator() + z0, z1 = zlocator.nonsingular(z0, z1) + if self._zmargin > 0: + delta = (z1 - z0) * self._zmargin + z0 -= delta + z1 += delta + if not _tight: + z0, z1 = zlocator.view_limits(z0, z1) + self.set_zbound(z0, z1, self._view_margin) + + def get_w_lims(self): + """Get 3D world limits.""" + minx, maxx = self.get_xlim3d() + miny, maxy = self.get_ylim3d() + minz, maxz = self.get_zlim3d() + return minx, maxx, miny, maxy, minz, maxz + + def _set_bound3d(self, get_bound, set_lim, axis_inverted, + lower=None, upper=None, view_margin=None): + """ + Set 3D axis bounds. + """ + if upper is None and np.iterable(lower): + lower, upper = lower + + old_lower, old_upper = get_bound() + if lower is None: + lower = old_lower + if upper is None: + upper = old_upper + + set_lim(sorted((lower, upper), reverse=bool(axis_inverted())), + auto=None, view_margin=view_margin) + + def set_xbound(self, lower=None, upper=None, view_margin=None): + """ + Set the lower and upper numerical bounds of the x-axis. + + This method will honor axis inversion regardless of parameter order. + It will not change the autoscaling setting (`.get_autoscalex_on()`). + + Parameters + ---------- + lower, upper : float or None + The lower and upper bounds. If *None*, the respective axis bound + is not modified. + view_margin : float or None + The margin to apply to the bounds. If *None*, the margin is handled + by `.set_xlim`. + + See Also + -------- + get_xbound + get_xlim, set_xlim + invert_xaxis, xaxis_inverted + """ + self._set_bound3d(self.get_xbound, self.set_xlim, self.xaxis_inverted, + lower, upper, view_margin) + + def set_ybound(self, lower=None, upper=None, view_margin=None): + """ + Set the lower and upper numerical bounds of the y-axis. + + This method will honor axis inversion regardless of parameter order. + It will not change the autoscaling setting (`.get_autoscaley_on()`). + + Parameters + ---------- + lower, upper : float or None + The lower and upper bounds. If *None*, the respective axis bound + is not modified. + view_margin : float or None + The margin to apply to the bounds. If *None*, the margin is handled + by `.set_ylim`. + + See Also + -------- + get_ybound + get_ylim, set_ylim + invert_yaxis, yaxis_inverted + """ + self._set_bound3d(self.get_ybound, self.set_ylim, self.yaxis_inverted, + lower, upper, view_margin) + + def set_zbound(self, lower=None, upper=None, view_margin=None): + """ + Set the lower and upper numerical bounds of the z-axis. + This method will honor axis inversion regardless of parameter order. + It will not change the autoscaling setting (`.get_autoscaley_on()`). + + Parameters + ---------- + lower, upper : float or None + The lower and upper bounds. If *None*, the respective axis bound + is not modified. + view_margin : float or None + The margin to apply to the bounds. If *None*, the margin is handled + by `.set_zlim`. + + See Also + -------- + get_zbound + get_zlim, set_zlim + invert_zaxis, zaxis_inverted + """ + self._set_bound3d(self.get_zbound, self.set_zlim, self.zaxis_inverted, + lower, upper, view_margin) + + def _set_lim3d(self, axis, lower=None, upper=None, *, emit=True, + auto=False, view_margin=None, axmin=None, axmax=None): + """ + Set 3D axis limits. + """ + if upper is None: + if np.iterable(lower): + lower, upper = lower + elif axmax is None: + upper = axis.get_view_interval()[1] + if lower is None and axmin is None: + lower = axis.get_view_interval()[0] + if axmin is not None: + if lower is not None: + raise TypeError("Cannot pass both 'lower' and 'min'") + lower = axmin + if axmax is not None: + if upper is not None: + raise TypeError("Cannot pass both 'upper' and 'max'") + upper = axmax + if np.isinf(lower) or np.isinf(upper): + raise ValueError(f"Axis limits {lower}, {upper} cannot be infinite") + if view_margin is None: + if mpl.rcParams['axes3d.automargin']: + view_margin = self._view_margin + else: + view_margin = 0 + delta = (upper - lower) * view_margin + lower -= delta + upper += delta + return axis._set_lim(lower, upper, emit=emit, auto=auto) + + def set_xlim(self, left=None, right=None, *, emit=True, auto=False, + view_margin=None, xmin=None, xmax=None): + """ + Set the 3D x-axis view limits. + + Parameters + ---------- + left : float, optional + The left xlim in data coordinates. Passing *None* leaves the + limit unchanged. + + The left and right xlims may also be passed as the tuple + (*left*, *right*) as the first positional argument (or as + the *left* keyword argument). + + .. ACCEPTS: (left: float, right: float) + + right : float, optional + The right xlim in data coordinates. Passing *None* leaves the + limit unchanged. + + emit : bool, default: True + Whether to notify observers of limit change. + + auto : bool or None, default: False + Whether to turn on autoscaling of the x-axis. *True* turns on, + *False* turns off, *None* leaves unchanged. + + view_margin : float, optional + The additional margin to apply to the limits. + + xmin, xmax : float, optional + They are equivalent to left and right respectively, and it is an + error to pass both *xmin* and *left* or *xmax* and *right*. + + Returns + ------- + left, right : (float, float) + The new x-axis limits in data coordinates. + + See Also + -------- + get_xlim + set_xbound, get_xbound + invert_xaxis, xaxis_inverted + + Notes + ----- + The *left* value may be greater than the *right* value, in which + case the x-axis values will decrease from *left* to *right*. + + Examples + -------- + >>> set_xlim(left, right) + >>> set_xlim((left, right)) + >>> left, right = set_xlim(left, right) + + One limit may be left unchanged. + + >>> set_xlim(right=right_lim) + + Limits may be passed in reverse order to flip the direction of + the x-axis. For example, suppose ``x`` represents depth of the + ocean in m. The x-axis limits might be set like the following + so 5000 m depth is at the left of the plot and the surface, + 0 m, is at the right. + + >>> set_xlim(5000, 0) + """ + return self._set_lim3d(self.xaxis, left, right, emit=emit, auto=auto, + view_margin=view_margin, axmin=xmin, axmax=xmax) + + def set_ylim(self, bottom=None, top=None, *, emit=True, auto=False, + view_margin=None, ymin=None, ymax=None): + """ + Set the 3D y-axis view limits. + + Parameters + ---------- + bottom : float, optional + The bottom ylim in data coordinates. Passing *None* leaves the + limit unchanged. + + The bottom and top ylims may also be passed as the tuple + (*bottom*, *top*) as the first positional argument (or as + the *bottom* keyword argument). + + .. ACCEPTS: (bottom: float, top: float) + + top : float, optional + The top ylim in data coordinates. Passing *None* leaves the + limit unchanged. + + emit : bool, default: True + Whether to notify observers of limit change. + + auto : bool or None, default: False + Whether to turn on autoscaling of the y-axis. *True* turns on, + *False* turns off, *None* leaves unchanged. + + view_margin : float, optional + The additional margin to apply to the limits. + + ymin, ymax : float, optional + They are equivalent to bottom and top respectively, and it is an + error to pass both *ymin* and *bottom* or *ymax* and *top*. + + Returns + ------- + bottom, top : (float, float) + The new y-axis limits in data coordinates. + + See Also + -------- + get_ylim + set_ybound, get_ybound + invert_yaxis, yaxis_inverted + + Notes + ----- + The *bottom* value may be greater than the *top* value, in which + case the y-axis values will decrease from *bottom* to *top*. + + Examples + -------- + >>> set_ylim(bottom, top) + >>> set_ylim((bottom, top)) + >>> bottom, top = set_ylim(bottom, top) + + One limit may be left unchanged. + + >>> set_ylim(top=top_lim) + + Limits may be passed in reverse order to flip the direction of + the y-axis. For example, suppose ``y`` represents depth of the + ocean in m. The y-axis limits might be set like the following + so 5000 m depth is at the bottom of the plot and the surface, + 0 m, is at the top. + + >>> set_ylim(5000, 0) + """ + return self._set_lim3d(self.yaxis, bottom, top, emit=emit, auto=auto, + view_margin=view_margin, axmin=ymin, axmax=ymax) + + def set_zlim(self, bottom=None, top=None, *, emit=True, auto=False, + view_margin=None, zmin=None, zmax=None): + """ + Set the 3D z-axis view limits. + + Parameters + ---------- + bottom : float, optional + The bottom zlim in data coordinates. Passing *None* leaves the + limit unchanged. + + The bottom and top zlims may also be passed as the tuple + (*bottom*, *top*) as the first positional argument (or as + the *bottom* keyword argument). + + .. ACCEPTS: (bottom: float, top: float) + + top : float, optional + The top zlim in data coordinates. Passing *None* leaves the + limit unchanged. + + emit : bool, default: True + Whether to notify observers of limit change. + + auto : bool or None, default: False + Whether to turn on autoscaling of the z-axis. *True* turns on, + *False* turns off, *None* leaves unchanged. + + view_margin : float, optional + The additional margin to apply to the limits. + + zmin, zmax : float, optional + They are equivalent to bottom and top respectively, and it is an + error to pass both *zmin* and *bottom* or *zmax* and *top*. + + Returns + ------- + bottom, top : (float, float) + The new z-axis limits in data coordinates. + + See Also + -------- + get_zlim + set_zbound, get_zbound + invert_zaxis, zaxis_inverted + + Notes + ----- + The *bottom* value may be greater than the *top* value, in which + case the z-axis values will decrease from *bottom* to *top*. + + Examples + -------- + >>> set_zlim(bottom, top) + >>> set_zlim((bottom, top)) + >>> bottom, top = set_zlim(bottom, top) + + One limit may be left unchanged. + + >>> set_zlim(top=top_lim) + + Limits may be passed in reverse order to flip the direction of + the z-axis. For example, suppose ``z`` represents depth of the + ocean in m. The z-axis limits might be set like the following + so 5000 m depth is at the bottom of the plot and the surface, + 0 m, is at the top. + + >>> set_zlim(5000, 0) + """ + return self._set_lim3d(self.zaxis, bottom, top, emit=emit, auto=auto, + view_margin=view_margin, axmin=zmin, axmax=zmax) + + set_xlim3d = set_xlim + set_ylim3d = set_ylim + set_zlim3d = set_zlim + + def get_xlim(self): + # docstring inherited + return tuple(self.xy_viewLim.intervalx) + + def get_ylim(self): + # docstring inherited + return tuple(self.xy_viewLim.intervaly) + + def get_zlim(self): + """ + Return the 3D z-axis view limits. + + Returns + ------- + left, right : (float, float) + The current z-axis limits in data coordinates. + + See Also + -------- + set_zlim + set_zbound, get_zbound + invert_zaxis, zaxis_inverted + + Notes + ----- + The z-axis may be inverted, in which case the *left* value will + be greater than the *right* value. + """ + return tuple(self.zz_viewLim.intervalx) + + get_zscale = _axis_method_wrapper("zaxis", "get_scale") + + # Redefine all three methods to overwrite their docstrings. + set_xscale = _axis_method_wrapper("xaxis", "_set_axes_scale") + set_yscale = _axis_method_wrapper("yaxis", "_set_axes_scale") + set_zscale = _axis_method_wrapper("zaxis", "_set_axes_scale") + set_xscale.__doc__, set_yscale.__doc__, set_zscale.__doc__ = map( + """ + Set the {}-axis scale. + + Parameters + ---------- + value : {{"linear"}} + The axis scale type to apply. 3D Axes currently only support + linear scales; other scales yield nonsensical results. + + **kwargs + Keyword arguments are nominally forwarded to the scale class, but + none of them is applicable for linear scales. + """.format, + ["x", "y", "z"]) + + get_zticks = _axis_method_wrapper("zaxis", "get_ticklocs") + set_zticks = _axis_method_wrapper("zaxis", "set_ticks") + get_zmajorticklabels = _axis_method_wrapper("zaxis", "get_majorticklabels") + get_zminorticklabels = _axis_method_wrapper("zaxis", "get_minorticklabels") + get_zticklabels = _axis_method_wrapper("zaxis", "get_ticklabels") + set_zticklabels = _axis_method_wrapper( + "zaxis", "set_ticklabels", + doc_sub={"Axis.set_ticks": "Axes3D.set_zticks"}) + + zaxis_date = _axis_method_wrapper("zaxis", "axis_date") + if zaxis_date.__doc__: + zaxis_date.__doc__ += textwrap.dedent(""" + + Notes + ----- + This function is merely provided for completeness, but 3D Axes do not + support dates for ticks, and so this may not work as expected. + """) + + def clabel(self, *args, **kwargs): + """Currently not implemented for 3D Axes, and returns *None*.""" + return None + + def view_init(self, elev=None, azim=None, roll=None, vertical_axis="z", + share=False): + """ + Set the elevation and azimuth of the Axes in degrees (not radians). + + This can be used to rotate the Axes programmatically. + + To look normal to the primary planes, the following elevation and + azimuth angles can be used. A roll angle of 0, 90, 180, or 270 deg + will rotate these views while keeping the axes at right angles. + + ========== ==== ==== + view plane elev azim + ========== ==== ==== + XY 90 -90 + XZ 0 -90 + YZ 0 0 + -XY -90 90 + -XZ 0 90 + -YZ 0 180 + ========== ==== ==== + + Parameters + ---------- + elev : float, default: None + The elevation angle in degrees rotates the camera above the plane + pierced by the vertical axis, with a positive angle corresponding + to a location above that plane. For example, with the default + vertical axis of 'z', the elevation defines the angle of the camera + location above the x-y plane. + If None, then the initial value as specified in the `Axes3D` + constructor is used. + azim : float, default: None + The azimuthal angle in degrees rotates the camera about the + vertical axis, with a positive angle corresponding to a + right-handed rotation. For example, with the default vertical axis + of 'z', a positive azimuth rotates the camera about the origin from + its location along the +x axis towards the +y axis. + If None, then the initial value as specified in the `Axes3D` + constructor is used. + roll : float, default: None + The roll angle in degrees rotates the camera about the viewing + axis. A positive angle spins the camera clockwise, causing the + scene to rotate counter-clockwise. + If None, then the initial value as specified in the `Axes3D` + constructor is used. + vertical_axis : {"z", "x", "y"}, default: "z" + The axis to align vertically. *azim* rotates about this axis. + share : bool, default: False + If ``True``, apply the settings to all Axes with shared views. + """ + + self._dist = 10 # The camera distance from origin. Behaves like zoom + + if elev is None: + elev = self.initial_elev + if azim is None: + azim = self.initial_azim + if roll is None: + roll = self.initial_roll + vertical_axis = _api.check_getitem( + {name: idx for idx, name in enumerate(self._axis_names)}, + vertical_axis=vertical_axis, + ) + + if share: + axes = {sibling for sibling + in self._shared_axes['view'].get_siblings(self)} + else: + axes = [self] + + for ax in axes: + ax.elev = elev + ax.azim = azim + ax.roll = roll + ax._vertical_axis = vertical_axis + + def set_proj_type(self, proj_type, focal_length=None): + """ + Set the projection type. + + Parameters + ---------- + proj_type : {'persp', 'ortho'} + The projection type. + focal_length : float, default: None + For a projection type of 'persp', the focal length of the virtual + camera. Must be > 0. If None, defaults to 1. + The focal length can be computed from a desired Field Of View via + the equation: focal_length = 1/tan(FOV/2) + """ + _api.check_in_list(['persp', 'ortho'], proj_type=proj_type) + if proj_type == 'persp': + if focal_length is None: + focal_length = 1 + elif focal_length <= 0: + raise ValueError(f"focal_length = {focal_length} must be " + "greater than 0") + self._focal_length = focal_length + else: # 'ortho': + if focal_length not in (None, np.inf): + raise ValueError(f"focal_length = {focal_length} must be " + f"None for proj_type = {proj_type}") + self._focal_length = np.inf + + def _roll_to_vertical( + self, arr: "np.typing.ArrayLike", reverse: bool = False + ) -> np.ndarray: + """ + Roll arrays to match the different vertical axis. + + Parameters + ---------- + arr : ArrayLike + Array to roll. + reverse : bool, default: False + Reverse the direction of the roll. + """ + if reverse: + return np.roll(arr, (self._vertical_axis - 2) * -1) + else: + return np.roll(arr, (self._vertical_axis - 2)) + + def get_proj(self): + """Create the projection matrix from the current viewing position.""" + + # Transform to uniform world coordinates 0-1, 0-1, 0-1 + box_aspect = self._roll_to_vertical(self._box_aspect) + worldM = proj3d.world_transformation( + *self.get_xlim3d(), + *self.get_ylim3d(), + *self.get_zlim3d(), + pb_aspect=box_aspect, + ) + + # Look into the middle of the world coordinates: + R = 0.5 * box_aspect + + # elev: elevation angle in the z plane. + # azim: azimuth angle in the xy plane. + # Coordinates for a point that rotates around the box of data. + # p0, p1 corresponds to rotating the box only around the vertical axis. + # p2 corresponds to rotating the box only around the horizontal axis. + elev_rad = np.deg2rad(self.elev) + azim_rad = np.deg2rad(self.azim) + p0 = np.cos(elev_rad) * np.cos(azim_rad) + p1 = np.cos(elev_rad) * np.sin(azim_rad) + p2 = np.sin(elev_rad) + + # When changing vertical axis the coordinates changes as well. + # Roll the values to get the same behaviour as the default: + ps = self._roll_to_vertical([p0, p1, p2]) + + # The coordinates for the eye viewing point. The eye is looking + # towards the middle of the box of data from a distance: + eye = R + self._dist * ps + + # Calculate the viewing axes for the eye position + u, v, w = self._calc_view_axes(eye) + self._view_u = u # _view_u is towards the right of the screen + self._view_v = v # _view_v is towards the top of the screen + self._view_w = w # _view_w is out of the screen + + # Generate the view and projection transformation matrices + if self._focal_length == np.inf: + # Orthographic projection + viewM = proj3d._view_transformation_uvw(u, v, w, eye) + projM = proj3d._ortho_transformation(-self._dist, self._dist) + else: + # Perspective projection + # Scale the eye dist to compensate for the focal length zoom effect + eye_focal = R + self._dist * ps * self._focal_length + viewM = proj3d._view_transformation_uvw(u, v, w, eye_focal) + projM = proj3d._persp_transformation(-self._dist, + self._dist, + self._focal_length) + + # Combine all the transformation matrices to get the final projection + M0 = np.dot(viewM, worldM) + M = np.dot(projM, M0) + return M + + def mouse_init(self, rotate_btn=1, pan_btn=2, zoom_btn=3): + """ + Set the mouse buttons for 3D rotation and zooming. + + Parameters + ---------- + rotate_btn : int or list of int, default: 1 + The mouse button or buttons to use for 3D rotation of the Axes. + pan_btn : int or list of int, default: 2 + The mouse button or buttons to use to pan the 3D Axes. + zoom_btn : int or list of int, default: 3 + The mouse button or buttons to use to zoom the 3D Axes. + """ + self.button_pressed = None + # coerce scalars into array-like, then convert into + # a regular list to avoid comparisons against None + # which breaks in recent versions of numpy. + self._rotate_btn = np.atleast_1d(rotate_btn).tolist() + self._pan_btn = np.atleast_1d(pan_btn).tolist() + self._zoom_btn = np.atleast_1d(zoom_btn).tolist() + + def disable_mouse_rotation(self): + """Disable mouse buttons for 3D rotation, panning, and zooming.""" + self.mouse_init(rotate_btn=[], pan_btn=[], zoom_btn=[]) + + def can_zoom(self): + # doc-string inherited + return True + + def can_pan(self): + # doc-string inherited + return True + + def sharez(self, other): + """ + Share the z-axis with *other*. + + This is equivalent to passing ``sharez=other`` when constructing the + Axes, and cannot be used if the z-axis is already being shared with + another Axes. Note that it is not possible to unshare axes. + """ + _api.check_isinstance(Axes3D, other=other) + if self._sharez is not None and other is not self._sharez: + raise ValueError("z-axis is already shared") + self._shared_axes["z"].join(self, other) + self._sharez = other + self.zaxis.major = other.zaxis.major # Ticker instances holding + self.zaxis.minor = other.zaxis.minor # locator and formatter. + z0, z1 = other.get_zlim() + self.set_zlim(z0, z1, emit=False, auto=other.get_autoscalez_on()) + self.zaxis._scale = other.zaxis._scale + + def shareview(self, other): + """ + Share the view angles with *other*. + + This is equivalent to passing ``shareview=other`` when constructing the + Axes, and cannot be used if the view angles are already being shared + with another Axes. Note that it is not possible to unshare axes. + """ + _api.check_isinstance(Axes3D, other=other) + if self._shareview is not None and other is not self._shareview: + raise ValueError("view angles are already shared") + self._shared_axes["view"].join(self, other) + self._shareview = other + vertical_axis = self._axis_names[other._vertical_axis] + self.view_init(elev=other.elev, azim=other.azim, roll=other.roll, + vertical_axis=vertical_axis, share=True) + + def clear(self): + # docstring inherited. + super().clear() + if self._focal_length == np.inf: + self._zmargin = mpl.rcParams['axes.zmargin'] + else: + self._zmargin = 0. + + xymargin = 0.05 * 10/11 # match mpl3.8 appearance + self.xy_dataLim = Bbox([[xymargin, xymargin], + [1 - xymargin, 1 - xymargin]]) + # z-limits are encoded in the x-component of the Bbox, y is un-used + self.zz_dataLim = Bbox.unit() + self._view_margin = 1/48 # default value to match mpl3.8 + self.autoscale_view() + + self.grid(mpl.rcParams['axes3d.grid']) + + def _button_press(self, event): + if event.inaxes == self: + self.button_pressed = event.button + self._sx, self._sy = event.xdata, event.ydata + toolbar = self.get_figure(root=True).canvas.toolbar + if toolbar and toolbar._nav_stack() is None: + toolbar.push_current() + if toolbar: + toolbar.set_message(toolbar._mouse_event_to_message(event)) + + def _button_release(self, event): + self.button_pressed = None + toolbar = self.get_figure(root=True).canvas.toolbar + # backend_bases.release_zoom and backend_bases.release_pan call + # push_current, so check the navigation mode so we don't call it twice + if toolbar and self.get_navigate_mode() is None: + toolbar.push_current() + if toolbar: + toolbar.set_message(toolbar._mouse_event_to_message(event)) + + def _get_view(self): + # docstring inherited + return { + "xlim": self.get_xlim(), "autoscalex_on": self.get_autoscalex_on(), + "ylim": self.get_ylim(), "autoscaley_on": self.get_autoscaley_on(), + "zlim": self.get_zlim(), "autoscalez_on": self.get_autoscalez_on(), + }, (self.elev, self.azim, self.roll) + + def _set_view(self, view): + # docstring inherited + props, (elev, azim, roll) = view + self.set(**props) + self.elev = elev + self.azim = azim + self.roll = roll + + def format_zdata(self, z): + """ + Return *z* string formatted. This function will use the + :attr:`fmt_zdata` attribute if it is callable, else will fall + back on the zaxis major formatter + """ + try: + return self.fmt_zdata(z) + except (AttributeError, TypeError): + func = self.zaxis.get_major_formatter().format_data_short + val = func(z) + return val + + def format_coord(self, xv, yv, renderer=None): + """ + Return a string giving the current view rotation angles, or the x, y, z + coordinates of the point on the nearest axis pane underneath the mouse + cursor, depending on the mouse button pressed. + """ + coords = '' + + if self.button_pressed in self._rotate_btn: + # ignore xv and yv and display angles instead + coords = self._rotation_coords() + + elif self.M is not None: + coords = self._location_coords(xv, yv, renderer) + + return coords + + def _rotation_coords(self): + """ + Return the rotation angles as a string. + """ + norm_elev = art3d._norm_angle(self.elev) + norm_azim = art3d._norm_angle(self.azim) + norm_roll = art3d._norm_angle(self.roll) + coords = (f"elevation={norm_elev:.0f}\N{DEGREE SIGN}, " + f"azimuth={norm_azim:.0f}\N{DEGREE SIGN}, " + f"roll={norm_roll:.0f}\N{DEGREE SIGN}" + ).replace("-", "\N{MINUS SIGN}") + return coords + + def _location_coords(self, xv, yv, renderer): + """ + Return the location on the axis pane underneath the cursor as a string. + """ + p1, pane_idx = self._calc_coord(xv, yv, renderer) + xs = self.format_xdata(p1[0]) + ys = self.format_ydata(p1[1]) + zs = self.format_zdata(p1[2]) + if pane_idx == 0: + coords = f'x pane={xs}, y={ys}, z={zs}' + elif pane_idx == 1: + coords = f'x={xs}, y pane={ys}, z={zs}' + elif pane_idx == 2: + coords = f'x={xs}, y={ys}, z pane={zs}' + return coords + + def _get_camera_loc(self): + """ + Returns the current camera location in data coordinates. + """ + cx, cy, cz, dx, dy, dz = self._get_w_centers_ranges() + c = np.array([cx, cy, cz]) + r = np.array([dx, dy, dz]) + + if self._focal_length == np.inf: # orthographic projection + focal_length = 1e9 # large enough to be effectively infinite + else: # perspective projection + focal_length = self._focal_length + eye = c + self._view_w * self._dist * r / self._box_aspect * focal_length + return eye + + def _calc_coord(self, xv, yv, renderer=None): + """ + Given the 2D view coordinates, find the point on the nearest axis pane + that lies directly below those coordinates. Returns a 3D point in data + coordinates. + """ + if self._focal_length == np.inf: # orthographic projection + zv = 1 + else: # perspective projection + zv = -1 / self._focal_length + + # Convert point on view plane to data coordinates + p1 = np.array(proj3d.inv_transform(xv, yv, zv, self.invM)).ravel() + + # Get the vector from the camera to the point on the view plane + vec = self._get_camera_loc() - p1 + + # Get the pane locations for each of the axes + pane_locs = [] + for axis in self._axis_map.values(): + xys, loc = axis.active_pane() + pane_locs.append(loc) + + # Find the distance to the nearest pane by projecting the view vector + scales = np.zeros(3) + for i in range(3): + if vec[i] == 0: + scales[i] = np.inf + else: + scales[i] = (p1[i] - pane_locs[i]) / vec[i] + pane_idx = np.argmin(abs(scales)) + scale = scales[pane_idx] + + # Calculate the point on the closest pane + p2 = p1 - scale*vec + return p2, pane_idx + + def _arcball(self, x: float, y: float) -> np.ndarray: + """ + Convert a point (x, y) to a point on a virtual trackball. + + This is Ken Shoemake's arcball (a sphere), modified + to soften the abrupt edge (optionally). + See: Ken Shoemake, "ARCBALL: A user interface for specifying + three-dimensional rotation using a mouse." in + Proceedings of Graphics Interface '92, 1992, pp. 151-156, + https://doi.org/10.20380/GI1992.18 + The smoothing of the edge is inspired by Gavin Bell's arcball + (a sphere combined with a hyperbola), but here, the sphere + is combined with a section of a cylinder, so it has finite support. + """ + s = mpl.rcParams['axes3d.trackballsize'] / 2 + b = mpl.rcParams['axes3d.trackballborder'] / s + x /= s + y /= s + r2 = x*x + y*y + r = np.sqrt(r2) + ra = 1 + b + a = b * (1 + b/2) + ri = 2/(ra + 1/ra) + if r < ri: + p = np.array([np.sqrt(1 - r2), x, y]) + elif r < ra: + dr = ra - r + p = np.array([a - np.sqrt((a + dr) * (a - dr)), x, y]) + p /= np.linalg.norm(p) + else: + p = np.array([0, x/r, y/r]) + return p + + def _on_move(self, event): + """ + Mouse moving. + + By default, button-1 rotates, button-2 pans, and button-3 zooms; + these buttons can be modified via `mouse_init`. + """ + + if not self.button_pressed: + return + + if self.get_navigate_mode() is not None: + # we don't want to rotate if we are zooming/panning + # from the toolbar + return + + if self.M is None: + return + + x, y = event.xdata, event.ydata + # In case the mouse is out of bounds. + if x is None or event.inaxes != self: + return + + dx, dy = x - self._sx, y - self._sy + w = self._pseudo_w + h = self._pseudo_h + + # Rotation + if self.button_pressed in self._rotate_btn: + # rotate viewing point + # get the x and y pixel coords + if dx == 0 and dy == 0: + return + + style = mpl.rcParams['axes3d.mouserotationstyle'] + if style == 'azel': + roll = np.deg2rad(self.roll) + delev = -(dy/h)*180*np.cos(roll) + (dx/w)*180*np.sin(roll) + dazim = -(dy/h)*180*np.sin(roll) - (dx/w)*180*np.cos(roll) + elev = self.elev + delev + azim = self.azim + dazim + roll = self.roll + else: + q = _Quaternion.from_cardan_angles( + *np.deg2rad((self.elev, self.azim, self.roll))) + + if style == 'trackball': + k = np.array([0, -dy/h, dx/w]) + nk = np.linalg.norm(k) + th = nk / mpl.rcParams['axes3d.trackballsize'] + dq = _Quaternion(np.cos(th), k*np.sin(th)/nk) + else: # 'sphere', 'arcball' + current_vec = self._arcball(self._sx/w, self._sy/h) + new_vec = self._arcball(x/w, y/h) + if style == 'sphere': + dq = _Quaternion.rotate_from_to(current_vec, new_vec) + else: # 'arcball' + dq = _Quaternion(0, new_vec) * _Quaternion(0, -current_vec) + + q = dq * q + elev, azim, roll = np.rad2deg(q.as_cardan_angles()) + + # update view + vertical_axis = self._axis_names[self._vertical_axis] + self.view_init( + elev=elev, + azim=azim, + roll=roll, + vertical_axis=vertical_axis, + share=True, + ) + self.stale = True + + # Pan + elif self.button_pressed in self._pan_btn: + # Start the pan event with pixel coordinates + px, py = self.transData.transform([self._sx, self._sy]) + self.start_pan(px, py, 2) + # pan view (takes pixel coordinate input) + self.drag_pan(2, None, event.x, event.y) + self.end_pan() + + # Zoom + elif self.button_pressed in self._zoom_btn: + # zoom view (dragging down zooms in) + scale = h/(h - dy) + self._scale_axis_limits(scale, scale, scale) + + # Store the event coordinates for the next time through. + self._sx, self._sy = x, y + # Always request a draw update at the end of interaction + self.get_figure(root=True).canvas.draw_idle() + + def drag_pan(self, button, key, x, y): + # docstring inherited + + # Get the coordinates from the move event + p = self._pan_start + (xdata, ydata), (xdata_start, ydata_start) = p.trans_inverse.transform( + [(x, y), (p.x, p.y)]) + self._sx, self._sy = xdata, ydata + # Calling start_pan() to set the x/y of this event as the starting + # move location for the next event + self.start_pan(x, y, button) + du, dv = xdata - xdata_start, ydata - ydata_start + dw = 0 + if key == 'x': + dv = 0 + elif key == 'y': + du = 0 + if du == 0 and dv == 0: + return + + # Transform the pan from the view axes to the data axes + R = np.array([self._view_u, self._view_v, self._view_w]) + R = -R / self._box_aspect * self._dist + duvw_projected = R.T @ np.array([du, dv, dw]) + + # Calculate pan distance + minx, maxx, miny, maxy, minz, maxz = self.get_w_lims() + dx = (maxx - minx) * duvw_projected[0] + dy = (maxy - miny) * duvw_projected[1] + dz = (maxz - minz) * duvw_projected[2] + + # Set the new axis limits + self.set_xlim3d(minx + dx, maxx + dx, auto=None) + self.set_ylim3d(miny + dy, maxy + dy, auto=None) + self.set_zlim3d(minz + dz, maxz + dz, auto=None) + + def _calc_view_axes(self, eye): + """ + Get the unit vectors for the viewing axes in data coordinates. + `u` is towards the right of the screen + `v` is towards the top of the screen + `w` is out of the screen + """ + elev_rad = np.deg2rad(art3d._norm_angle(self.elev)) + roll_rad = np.deg2rad(art3d._norm_angle(self.roll)) + + # Look into the middle of the world coordinates + R = 0.5 * self._roll_to_vertical(self._box_aspect) + + # Define which axis should be vertical. A negative value + # indicates the plot is upside down and therefore the values + # have been reversed: + V = np.zeros(3) + V[self._vertical_axis] = -1 if abs(elev_rad) > np.pi/2 else 1 + + u, v, w = proj3d._view_axes(eye, R, V, roll_rad) + return u, v, w + + def _set_view_from_bbox(self, bbox, direction='in', + mode=None, twinx=False, twiny=False): + """ + Zoom in or out of the bounding box. + + Will center the view in the center of the bounding box, and zoom by + the ratio of the size of the bounding box to the size of the Axes3D. + """ + (start_x, start_y, stop_x, stop_y) = bbox + if mode == 'x': + start_y = self.bbox.min[1] + stop_y = self.bbox.max[1] + elif mode == 'y': + start_x = self.bbox.min[0] + stop_x = self.bbox.max[0] + + # Clip to bounding box limits + start_x, stop_x = np.clip(sorted([start_x, stop_x]), + self.bbox.min[0], self.bbox.max[0]) + start_y, stop_y = np.clip(sorted([start_y, stop_y]), + self.bbox.min[1], self.bbox.max[1]) + + # Move the center of the view to the center of the bbox + zoom_center_x = (start_x + stop_x)/2 + zoom_center_y = (start_y + stop_y)/2 + + ax_center_x = (self.bbox.max[0] + self.bbox.min[0])/2 + ax_center_y = (self.bbox.max[1] + self.bbox.min[1])/2 + + self.start_pan(zoom_center_x, zoom_center_y, 2) + self.drag_pan(2, None, ax_center_x, ax_center_y) + self.end_pan() + + # Calculate zoom level + dx = abs(start_x - stop_x) + dy = abs(start_y - stop_y) + scale_u = dx / (self.bbox.max[0] - self.bbox.min[0]) + scale_v = dy / (self.bbox.max[1] - self.bbox.min[1]) + + # Keep aspect ratios equal + scale = max(scale_u, scale_v) + + # Zoom out + if direction == 'out': + scale = 1 / scale + + self._zoom_data_limits(scale, scale, scale) + + def _zoom_data_limits(self, scale_u, scale_v, scale_w): + """ + Zoom in or out of a 3D plot. + + Will scale the data limits by the scale factors. These will be + transformed to the x, y, z data axes based on the current view angles. + A scale factor > 1 zooms out and a scale factor < 1 zooms in. + + For an Axes that has had its aspect ratio set to 'equal', 'equalxy', + 'equalyz', or 'equalxz', the relevant axes are constrained to zoom + equally. + + Parameters + ---------- + scale_u : float + Scale factor for the u view axis (view screen horizontal). + scale_v : float + Scale factor for the v view axis (view screen vertical). + scale_w : float + Scale factor for the w view axis (view screen depth). + """ + scale = np.array([scale_u, scale_v, scale_w]) + + # Only perform frame conversion if unequal scale factors + if not np.allclose(scale, scale_u): + # Convert the scale factors from the view frame to the data frame + R = np.array([self._view_u, self._view_v, self._view_w]) + S = scale * np.eye(3) + scale = np.linalg.norm(R.T @ S, axis=1) + + # Set the constrained scale factors to the factor closest to 1 + if self._aspect in ('equal', 'equalxy', 'equalxz', 'equalyz'): + ax_idxs = self._equal_aspect_axis_indices(self._aspect) + min_ax_idxs = np.argmin(np.abs(scale[ax_idxs] - 1)) + scale[ax_idxs] = scale[ax_idxs][min_ax_idxs] + + self._scale_axis_limits(scale[0], scale[1], scale[2]) + + def _scale_axis_limits(self, scale_x, scale_y, scale_z): + """ + Keeping the center of the x, y, and z data axes fixed, scale their + limits by scale factors. A scale factor > 1 zooms out and a scale + factor < 1 zooms in. + + Parameters + ---------- + scale_x : float + Scale factor for the x data axis. + scale_y : float + Scale factor for the y data axis. + scale_z : float + Scale factor for the z data axis. + """ + # Get the axis centers and ranges + cx, cy, cz, dx, dy, dz = self._get_w_centers_ranges() + + # Set the scaled axis limits + self.set_xlim3d(cx - dx*scale_x/2, cx + dx*scale_x/2, auto=None) + self.set_ylim3d(cy - dy*scale_y/2, cy + dy*scale_y/2, auto=None) + self.set_zlim3d(cz - dz*scale_z/2, cz + dz*scale_z/2, auto=None) + + def _get_w_centers_ranges(self): + """Get 3D world centers and axis ranges.""" + # Calculate center of axis limits + minx, maxx, miny, maxy, minz, maxz = self.get_w_lims() + cx = (maxx + minx)/2 + cy = (maxy + miny)/2 + cz = (maxz + minz)/2 + + # Calculate range of axis limits + dx = (maxx - minx) + dy = (maxy - miny) + dz = (maxz - minz) + return cx, cy, cz, dx, dy, dz + + def set_zlabel(self, zlabel, fontdict=None, labelpad=None, **kwargs): + """ + Set zlabel. See doc for `.set_ylabel` for description. + """ + if labelpad is not None: + self.zaxis.labelpad = labelpad + return self.zaxis.set_label_text(zlabel, fontdict, **kwargs) + + def get_zlabel(self): + """ + Get the z-label text string. + """ + label = self.zaxis.label + return label.get_text() + + # Axes rectangle characteristics + + # The frame_on methods are not available for 3D axes. + # Python will raise a TypeError if they are called. + get_frame_on = None + set_frame_on = None + + def grid(self, visible=True, **kwargs): + """ + Set / unset 3D grid. + + .. note:: + + Currently, this function does not behave the same as + `.axes.Axes.grid`, but it is intended to eventually support that + behavior. + """ + # TODO: Operate on each axes separately + if len(kwargs): + visible = True + self._draw_grid = visible + self.stale = True + + def tick_params(self, axis='both', **kwargs): + """ + Convenience method for changing the appearance of ticks and + tick labels. + + See `.Axes.tick_params` for full documentation. Because this function + applies to 3D Axes, *axis* can also be set to 'z', and setting *axis* + to 'both' autoscales all three axes. + + Also, because of how Axes3D objects are drawn very differently + from regular 2D Axes, some of these settings may have + ambiguous meaning. For simplicity, the 'z' axis will + accept settings as if it was like the 'y' axis. + + .. note:: + Axes3D currently ignores some of these settings. + """ + _api.check_in_list(['x', 'y', 'z', 'both'], axis=axis) + if axis in ['x', 'y', 'both']: + super().tick_params(axis, **kwargs) + if axis in ['z', 'both']: + zkw = dict(kwargs) + zkw.pop('top', None) + zkw.pop('bottom', None) + zkw.pop('labeltop', None) + zkw.pop('labelbottom', None) + self.zaxis.set_tick_params(**zkw) + + # data limits, ticks, tick labels, and formatting + + def invert_zaxis(self): + """ + Invert the z-axis. + + See Also + -------- + zaxis_inverted + get_zlim, set_zlim + get_zbound, set_zbound + """ + bottom, top = self.get_zlim() + self.set_zlim(top, bottom, auto=None) + + zaxis_inverted = _axis_method_wrapper("zaxis", "get_inverted") + + def get_zbound(self): + """ + Return the lower and upper z-axis bounds, in increasing order. + + See Also + -------- + set_zbound + get_zlim, set_zlim + invert_zaxis, zaxis_inverted + """ + lower, upper = self.get_zlim() + if lower < upper: + return lower, upper + else: + return upper, lower + + def text(self, x, y, z, s, zdir=None, *, axlim_clip=False, **kwargs): + """ + Add the text *s* to the 3D Axes at location *x*, *y*, *z* in data coordinates. + + Parameters + ---------- + x, y, z : float + The position to place the text. + s : str + The text. + zdir : {'x', 'y', 'z', 3-tuple}, optional + The direction to be used as the z-direction. Default: 'z'. + See `.get_dir_vector` for a description of the values. + axlim_clip : bool, default: False + Whether to hide text that is outside the axes view limits. + + .. versionadded:: 3.10 + **kwargs + Other arguments are forwarded to `matplotlib.axes.Axes.text`. + + Returns + ------- + `.Text3D` + The created `.Text3D` instance. + """ + text = super().text(x, y, s, **kwargs) + art3d.text_2d_to_3d(text, z, zdir, axlim_clip) + return text + + text3D = text + text2D = Axes.text + + def plot(self, xs, ys, *args, zdir='z', axlim_clip=False, **kwargs): + """ + Plot 2D or 3D data. + + Parameters + ---------- + xs : 1D array-like + x coordinates of vertices. + ys : 1D array-like + y coordinates of vertices. + zs : float or 1D array-like + z coordinates of vertices; either one for all points or one for + each point. + zdir : {'x', 'y', 'z'}, default: 'z' + When plotting 2D data, the direction to use as z. + axlim_clip : bool, default: False + Whether to hide data that is outside the axes view limits. + + .. versionadded:: 3.10 + **kwargs + Other arguments are forwarded to `matplotlib.axes.Axes.plot`. + """ + had_data = self.has_data() + + # `zs` can be passed positionally or as keyword; checking whether + # args[0] is a string matches the behavior of 2D `plot` (via + # `_process_plot_var_args`). + if args and not isinstance(args[0], str): + zs, *args = args + if 'zs' in kwargs: + raise TypeError("plot() for multiple values for argument 'zs'") + else: + zs = kwargs.pop('zs', 0) + + xs, ys, zs = cbook._broadcast_with_masks(xs, ys, zs) + + lines = super().plot(xs, ys, *args, **kwargs) + for line in lines: + art3d.line_2d_to_3d(line, zs=zs, zdir=zdir, axlim_clip=axlim_clip) + + xs, ys, zs = art3d.juggle_axes(xs, ys, zs, zdir) + self.auto_scale_xyz(xs, ys, zs, had_data) + return lines + + plot3D = plot + + def fill_between(self, x1, y1, z1, x2, y2, z2, *, + where=None, mode='auto', facecolors=None, shade=None, + axlim_clip=False, **kwargs): + """ + Fill the area between two 3D curves. + + The curves are defined by the points (*x1*, *y1*, *z1*) and + (*x2*, *y2*, *z2*). This creates one or multiple quadrangle + polygons that are filled. All points must be the same length N, or a + single value to be used for all points. + + Parameters + ---------- + x1, y1, z1 : float or 1D array-like + x, y, and z coordinates of vertices for 1st line. + + x2, y2, z2 : float or 1D array-like + x, y, and z coordinates of vertices for 2nd line. + + where : array of bool (length N), optional + Define *where* to exclude some regions from being filled. The + filled regions are defined by the coordinates ``pts[where]``, + for all x, y, and z pts. More precisely, fill between ``pts[i]`` + and ``pts[i+1]`` if ``where[i] and where[i+1]``. Note that this + definition implies that an isolated *True* value between two + *False* values in *where* will not result in filling. Both sides of + the *True* position remain unfilled due to the adjacent *False* + values. + + mode : {'quad', 'polygon', 'auto'}, default: 'auto' + The fill mode. One of: + + - 'quad': A separate quadrilateral polygon is created for each + pair of subsequent points in the two lines. + - 'polygon': The two lines are connected to form a single polygon. + This is faster and can render more cleanly for simple shapes + (e.g. for filling between two lines that lie within a plane). + - 'auto': If the points all lie on the same 3D plane, 'polygon' is + used. Otherwise, 'quad' is used. + + facecolors : list of :mpltype:`color`, default: None + Colors of each individual patch, or a single color to be used for + all patches. + + shade : bool, default: None + Whether to shade the facecolors. If *None*, then defaults to *True* + for 'quad' mode and *False* for 'polygon' mode. + + axlim_clip : bool, default: False + Whether to hide data that is outside the axes view limits. + + .. versionadded:: 3.10 + + **kwargs + All other keyword arguments are passed on to `.Poly3DCollection`. + + Returns + ------- + `.Poly3DCollection` + A `.Poly3DCollection` containing the plotted polygons. + + """ + _api.check_in_list(['auto', 'quad', 'polygon'], mode=mode) + + had_data = self.has_data() + x1, y1, z1, x2, y2, z2 = cbook._broadcast_with_masks(x1, y1, z1, x2, y2, z2) + + if facecolors is None: + facecolors = [self._get_patches_for_fill.get_next_color()] + facecolors = list(mcolors.to_rgba_array(facecolors)) + + if where is None: + where = True + else: + where = np.asarray(where, dtype=bool) + if where.size != x1.size: + raise ValueError(f"where size ({where.size}) does not match " + f"size ({x1.size})") + where = where & ~np.isnan(x1) # NaNs were broadcast in _broadcast_with_masks + + if mode == 'auto': + if art3d._all_points_on_plane(np.concatenate((x1[where], x2[where])), + np.concatenate((y1[where], y2[where])), + np.concatenate((z1[where], z2[where])), + atol=1e-12): + mode = 'polygon' + else: + mode = 'quad' + + if shade is None: + if mode == 'quad': + shade = True + else: + shade = False + + polys = [] + for idx0, idx1 in cbook.contiguous_regions(where): + x1i = x1[idx0:idx1] + y1i = y1[idx0:idx1] + z1i = z1[idx0:idx1] + x2i = x2[idx0:idx1] + y2i = y2[idx0:idx1] + z2i = z2[idx0:idx1] + + if not len(x1i): + continue + + if mode == 'quad': + # Preallocate the array for the region's vertices, and fill it in + n_polys_i = len(x1i) - 1 + polys_i = np.empty((n_polys_i, 4, 3)) + polys_i[:, 0, :] = np.column_stack((x1i[:-1], y1i[:-1], z1i[:-1])) + polys_i[:, 1, :] = np.column_stack((x1i[1:], y1i[1:], z1i[1:])) + polys_i[:, 2, :] = np.column_stack((x2i[1:], y2i[1:], z2i[1:])) + polys_i[:, 3, :] = np.column_stack((x2i[:-1], y2i[:-1], z2i[:-1])) + polys = polys + [*polys_i] + elif mode == 'polygon': + line1 = np.column_stack((x1i, y1i, z1i)) + line2 = np.column_stack((x2i[::-1], y2i[::-1], z2i[::-1])) + poly = np.concatenate((line1, line2), axis=0) + polys.append(poly) + + polyc = art3d.Poly3DCollection(polys, facecolors=facecolors, shade=shade, + axlim_clip=axlim_clip, **kwargs) + self.add_collection(polyc) + + self.auto_scale_xyz([x1, x2], [y1, y2], [z1, z2], had_data) + return polyc + + def plot_surface(self, X, Y, Z, *, norm=None, vmin=None, + vmax=None, lightsource=None, axlim_clip=False, **kwargs): + """ + Create a surface plot. + + By default, it will be colored in shades of a solid color, but it also + supports colormapping by supplying the *cmap* argument. + + .. note:: + + The *rcount* and *ccount* kwargs, which both default to 50, + determine the maximum number of samples used in each direction. If + the input data is larger, it will be downsampled (by slicing) to + these numbers of points. + + .. note:: + + To maximize rendering speed consider setting *rstride* and *cstride* + to divisors of the number of rows minus 1 and columns minus 1 + respectively. For example, given 51 rows rstride can be any of the + divisors of 50. + + Similarly, a setting of *rstride* and *cstride* equal to 1 (or + *rcount* and *ccount* equal the number of rows and columns) can use + the optimized path. + + Parameters + ---------- + X, Y, Z : 2D arrays + Data values. + + rcount, ccount : int + Maximum number of samples used in each direction. If the input + data is larger, it will be downsampled (by slicing) to these + numbers of points. Defaults to 50. + + rstride, cstride : int + Downsampling stride in each direction. These arguments are + mutually exclusive with *rcount* and *ccount*. If only one of + *rstride* or *cstride* is set, the other defaults to 10. + + 'classic' mode uses a default of ``rstride = cstride = 10`` instead + of the new default of ``rcount = ccount = 50``. + + color : :mpltype:`color` + Color of the surface patches. + + cmap : Colormap, optional + Colormap of the surface patches. + + facecolors : list of :mpltype:`color` + Colors of each individual patch. + + norm : `~matplotlib.colors.Normalize`, optional + Normalization for the colormap. + + vmin, vmax : float, optional + Bounds for the normalization. + + shade : bool, default: True + Whether to shade the facecolors. Shading is always disabled when + *cmap* is specified. + + lightsource : `~matplotlib.colors.LightSource`, optional + The lightsource to use when *shade* is True. + + axlim_clip : bool, default: False + Whether to hide patches with a vertex outside the axes view limits. + + .. versionadded:: 3.10 + + **kwargs + Other keyword arguments are forwarded to `.Poly3DCollection`. + """ + + had_data = self.has_data() + + if Z.ndim != 2: + raise ValueError("Argument Z must be 2-dimensional.") + + Z = cbook._to_unmasked_float_array(Z) + X, Y, Z = np.broadcast_arrays(X, Y, Z) + rows, cols = Z.shape + + has_stride = 'rstride' in kwargs or 'cstride' in kwargs + has_count = 'rcount' in kwargs or 'ccount' in kwargs + + if has_stride and has_count: + raise ValueError("Cannot specify both stride and count arguments") + + rstride = kwargs.pop('rstride', 10) + cstride = kwargs.pop('cstride', 10) + rcount = kwargs.pop('rcount', 50) + ccount = kwargs.pop('ccount', 50) + + if mpl.rcParams['_internal.classic_mode']: + # Strides have priority over counts in classic mode. + # So, only compute strides from counts + # if counts were explicitly given + compute_strides = has_count + else: + # If the strides are provided then it has priority. + # Otherwise, compute the strides from the counts. + compute_strides = not has_stride + + if compute_strides: + rstride = int(max(np.ceil(rows / rcount), 1)) + cstride = int(max(np.ceil(cols / ccount), 1)) + + fcolors = kwargs.pop('facecolors', None) + + cmap = kwargs.get('cmap', None) + shade = kwargs.pop('shade', cmap is None) + if shade is None: + raise ValueError("shade cannot be None.") + + colset = [] # the sampled facecolor + if (rows - 1) % rstride == 0 and \ + (cols - 1) % cstride == 0 and \ + fcolors is None: + polys = np.stack( + [cbook._array_patch_perimeters(a, rstride, cstride) + for a in (X, Y, Z)], + axis=-1) + else: + # evenly spaced, and including both endpoints + row_inds = list(range(0, rows-1, rstride)) + [rows-1] + col_inds = list(range(0, cols-1, cstride)) + [cols-1] + + polys = [] + for rs, rs_next in itertools.pairwise(row_inds): + for cs, cs_next in itertools.pairwise(col_inds): + ps = [ + # +1 ensures we share edges between polygons + cbook._array_perimeter(a[rs:rs_next+1, cs:cs_next+1]) + for a in (X, Y, Z) + ] + # ps = np.stack(ps, axis=-1) + ps = np.array(ps).T + polys.append(ps) + + if fcolors is not None: + colset.append(fcolors[rs][cs]) + + # In cases where there are non-finite values in the data (possibly NaNs from + # masked arrays), artifacts can be introduced. Here check whether such values + # are present and remove them. + if not isinstance(polys, np.ndarray) or not np.isfinite(polys).all(): + new_polys = [] + new_colset = [] + + # Depending on fcolors, colset is either an empty list or has as + # many elements as polys. In the former case new_colset results in + # a list with None entries, that is discarded later. + for p, col in itertools.zip_longest(polys, colset): + new_poly = np.array(p)[np.isfinite(p).all(axis=1)] + if len(new_poly): + new_polys.append(new_poly) + new_colset.append(col) + + # Replace previous polys and, if fcolors is not None, colset + polys = new_polys + if fcolors is not None: + colset = new_colset + + # note that the striding causes some polygons to have more coordinates + # than others + + if fcolors is not None: + polyc = art3d.Poly3DCollection( + polys, edgecolors=colset, facecolors=colset, shade=shade, + lightsource=lightsource, axlim_clip=axlim_clip, **kwargs) + elif cmap: + polyc = art3d.Poly3DCollection(polys, axlim_clip=axlim_clip, **kwargs) + # can't always vectorize, because polys might be jagged + if isinstance(polys, np.ndarray): + avg_z = polys[..., 2].mean(axis=-1) + else: + avg_z = np.array([ps[:, 2].mean() for ps in polys]) + polyc.set_array(avg_z) + if vmin is not None or vmax is not None: + polyc.set_clim(vmin, vmax) + if norm is not None: + polyc.set_norm(norm) + else: + color = kwargs.pop('color', None) + if color is None: + color = self._get_lines.get_next_color() + color = np.array(mcolors.to_rgba(color)) + + polyc = art3d.Poly3DCollection( + polys, facecolors=color, shade=shade, lightsource=lightsource, + axlim_clip=axlim_clip, **kwargs) + + self.add_collection(polyc) + self.auto_scale_xyz(X, Y, Z, had_data) + + return polyc + + def plot_wireframe(self, X, Y, Z, *, axlim_clip=False, **kwargs): + """ + Plot a 3D wireframe. + + .. note:: + + The *rcount* and *ccount* kwargs, which both default to 50, + determine the maximum number of samples used in each direction. If + the input data is larger, it will be downsampled (by slicing) to + these numbers of points. + + Parameters + ---------- + X, Y, Z : 2D arrays + Data values. + + axlim_clip : bool, default: False + Whether to hide lines and patches with vertices outside the axes + view limits. + + .. versionadded:: 3.10 + + rcount, ccount : int + Maximum number of samples used in each direction. If the input + data is larger, it will be downsampled (by slicing) to these + numbers of points. Setting a count to zero causes the data to be + not sampled in the corresponding direction, producing a 3D line + plot rather than a wireframe plot. Defaults to 50. + + rstride, cstride : int + Downsampling stride in each direction. These arguments are + mutually exclusive with *rcount* and *ccount*. If only one of + *rstride* or *cstride* is set, the other defaults to 1. Setting a + stride to zero causes the data to be not sampled in the + corresponding direction, producing a 3D line plot rather than a + wireframe plot. + + 'classic' mode uses a default of ``rstride = cstride = 1`` instead + of the new default of ``rcount = ccount = 50``. + + **kwargs + Other keyword arguments are forwarded to `.Line3DCollection`. + """ + + had_data = self.has_data() + if Z.ndim != 2: + raise ValueError("Argument Z must be 2-dimensional.") + # FIXME: Support masked arrays + X, Y, Z = np.broadcast_arrays(X, Y, Z) + rows, cols = Z.shape + + has_stride = 'rstride' in kwargs or 'cstride' in kwargs + has_count = 'rcount' in kwargs or 'ccount' in kwargs + + if has_stride and has_count: + raise ValueError("Cannot specify both stride and count arguments") + + rstride = kwargs.pop('rstride', 1) + cstride = kwargs.pop('cstride', 1) + rcount = kwargs.pop('rcount', 50) + ccount = kwargs.pop('ccount', 50) + + if mpl.rcParams['_internal.classic_mode']: + # Strides have priority over counts in classic mode. + # So, only compute strides from counts + # if counts were explicitly given + if has_count: + rstride = int(max(np.ceil(rows / rcount), 1)) if rcount else 0 + cstride = int(max(np.ceil(cols / ccount), 1)) if ccount else 0 + else: + # If the strides are provided then it has priority. + # Otherwise, compute the strides from the counts. + if not has_stride: + rstride = int(max(np.ceil(rows / rcount), 1)) if rcount else 0 + cstride = int(max(np.ceil(cols / ccount), 1)) if ccount else 0 + + # We want two sets of lines, one running along the "rows" of + # Z and another set of lines running along the "columns" of Z. + # This transpose will make it easy to obtain the columns. + tX, tY, tZ = np.transpose(X), np.transpose(Y), np.transpose(Z) + + if rstride: + rii = list(range(0, rows, rstride)) + # Add the last index only if needed + if rows > 0 and rii[-1] != (rows - 1): + rii += [rows-1] + else: + rii = [] + if cstride: + cii = list(range(0, cols, cstride)) + # Add the last index only if needed + if cols > 0 and cii[-1] != (cols - 1): + cii += [cols-1] + else: + cii = [] + + if rstride == 0 and cstride == 0: + raise ValueError("Either rstride or cstride must be non zero") + + # If the inputs were empty, then just + # reset everything. + if Z.size == 0: + rii = [] + cii = [] + + xlines = [X[i] for i in rii] + ylines = [Y[i] for i in rii] + zlines = [Z[i] for i in rii] + + txlines = [tX[i] for i in cii] + tylines = [tY[i] for i in cii] + tzlines = [tZ[i] for i in cii] + + lines = ([list(zip(xl, yl, zl)) + for xl, yl, zl in zip(xlines, ylines, zlines)] + + [list(zip(xl, yl, zl)) + for xl, yl, zl in zip(txlines, tylines, tzlines)]) + + linec = art3d.Line3DCollection(lines, axlim_clip=axlim_clip, **kwargs) + self.add_collection(linec) + self.auto_scale_xyz(X, Y, Z, had_data) + + return linec + + def plot_trisurf(self, *args, color=None, norm=None, vmin=None, vmax=None, + lightsource=None, axlim_clip=False, **kwargs): + """ + Plot a triangulated surface. + + The (optional) triangulation can be specified in one of two ways; + either:: + + plot_trisurf(triangulation, ...) + + where triangulation is a `~matplotlib.tri.Triangulation` object, or:: + + plot_trisurf(X, Y, ...) + plot_trisurf(X, Y, triangles, ...) + plot_trisurf(X, Y, triangles=triangles, ...) + + in which case a Triangulation object will be created. See + `.Triangulation` for an explanation of these possibilities. + + The remaining arguments are:: + + plot_trisurf(..., Z) + + where *Z* is the array of values to contour, one per point + in the triangulation. + + Parameters + ---------- + X, Y, Z : array-like + Data values as 1D arrays. + color + Color of the surface patches. + cmap + A colormap for the surface patches. + norm : `~matplotlib.colors.Normalize`, optional + An instance of Normalize to map values to colors. + vmin, vmax : float, optional + Minimum and maximum value to map. + shade : bool, default: True + Whether to shade the facecolors. Shading is always disabled when + *cmap* is specified. + lightsource : `~matplotlib.colors.LightSource`, optional + The lightsource to use when *shade* is True. + axlim_clip : bool, default: False + Whether to hide patches with a vertex outside the axes view limits. + + .. versionadded:: 3.10 + **kwargs + All other keyword arguments are passed on to + :class:`~mpl_toolkits.mplot3d.art3d.Poly3DCollection` + + Examples + -------- + .. plot:: gallery/mplot3d/trisurf3d.py + .. plot:: gallery/mplot3d/trisurf3d_2.py + """ + + had_data = self.has_data() + + # TODO: Support custom face colours + if color is None: + color = self._get_lines.get_next_color() + color = np.array(mcolors.to_rgba(color)) + + cmap = kwargs.get('cmap', None) + shade = kwargs.pop('shade', cmap is None) + + tri, args, kwargs = \ + Triangulation.get_from_args_and_kwargs(*args, **kwargs) + try: + z = kwargs.pop('Z') + except KeyError: + # We do this so Z doesn't get passed as an arg to PolyCollection + z, *args = args + z = np.asarray(z) + + triangles = tri.get_masked_triangles() + xt = tri.x[triangles] + yt = tri.y[triangles] + zt = z[triangles] + verts = np.stack((xt, yt, zt), axis=-1) + + if cmap: + polyc = art3d.Poly3DCollection(verts, *args, + axlim_clip=axlim_clip, **kwargs) + # average over the three points of each triangle + avg_z = verts[:, :, 2].mean(axis=1) + polyc.set_array(avg_z) + if vmin is not None or vmax is not None: + polyc.set_clim(vmin, vmax) + if norm is not None: + polyc.set_norm(norm) + else: + polyc = art3d.Poly3DCollection( + verts, *args, shade=shade, lightsource=lightsource, + facecolors=color, axlim_clip=axlim_clip, **kwargs) + + self.add_collection(polyc) + self.auto_scale_xyz(tri.x, tri.y, z, had_data) + + return polyc + + def _3d_extend_contour(self, cset, stride=5): + """ + Extend a contour in 3D by creating + """ + + dz = (cset.levels[1] - cset.levels[0]) / 2 + polyverts = [] + colors = [] + for idx, level in enumerate(cset.levels): + path = cset.get_paths()[idx] + subpaths = [*path._iter_connected_components()] + color = cset.get_edgecolor()[idx] + top = art3d._paths_to_3d_segments(subpaths, level - dz) + bot = art3d._paths_to_3d_segments(subpaths, level + dz) + if not len(top[0]): + continue + nsteps = max(round(len(top[0]) / stride), 2) + stepsize = (len(top[0]) - 1) / (nsteps - 1) + polyverts.extend([ + (top[0][round(i * stepsize)], top[0][round((i + 1) * stepsize)], + bot[0][round((i + 1) * stepsize)], bot[0][round(i * stepsize)]) + for i in range(round(nsteps) - 1)]) + colors.extend([color] * (round(nsteps) - 1)) + self.add_collection3d(art3d.Poly3DCollection( + np.array(polyverts), # All polygons have 4 vertices, so vectorize. + facecolors=colors, edgecolors=colors, shade=True)) + cset.remove() + + def add_contour_set( + self, cset, extend3d=False, stride=5, zdir='z', offset=None, + axlim_clip=False): + zdir = '-' + zdir + if extend3d: + self._3d_extend_contour(cset, stride) + else: + art3d.collection_2d_to_3d( + cset, zs=offset if offset is not None else cset.levels, zdir=zdir, + axlim_clip=axlim_clip) + + def add_contourf_set(self, cset, zdir='z', offset=None, *, axlim_clip=False): + self._add_contourf_set(cset, zdir=zdir, offset=offset, + axlim_clip=axlim_clip) + + def _add_contourf_set(self, cset, zdir='z', offset=None, axlim_clip=False): + """ + Returns + ------- + levels : `numpy.ndarray` + Levels at which the filled contours are added. + """ + zdir = '-' + zdir + + midpoints = cset.levels[:-1] + np.diff(cset.levels) / 2 + # Linearly interpolate to get levels for any extensions + if cset._extend_min: + min_level = cset.levels[0] - np.diff(cset.levels[:2]) / 2 + midpoints = np.insert(midpoints, 0, min_level) + if cset._extend_max: + max_level = cset.levels[-1] + np.diff(cset.levels[-2:]) / 2 + midpoints = np.append(midpoints, max_level) + + art3d.collection_2d_to_3d( + cset, zs=offset if offset is not None else midpoints, zdir=zdir, + axlim_clip=axlim_clip) + return midpoints + + @_preprocess_data() + def contour(self, X, Y, Z, *args, + extend3d=False, stride=5, zdir='z', offset=None, axlim_clip=False, + **kwargs): + """ + Create a 3D contour plot. + + Parameters + ---------- + X, Y, Z : array-like, + Input data. See `.Axes.contour` for supported data shapes. + extend3d : bool, default: False + Whether to extend contour in 3D. + stride : int, default: 5 + Step size for extending contour. + zdir : {'x', 'y', 'z'}, default: 'z' + The direction to use. + offset : float, optional + If specified, plot a projection of the contour lines at this + position in a plane normal to *zdir*. + axlim_clip : bool, default: False + Whether to hide lines with a vertex outside the axes view limits. + + .. versionadded:: 3.10 + data : indexable object, optional + DATA_PARAMETER_PLACEHOLDER + + *args, **kwargs + Other arguments are forwarded to `matplotlib.axes.Axes.contour`. + + Returns + ------- + matplotlib.contour.QuadContourSet + """ + had_data = self.has_data() + + jX, jY, jZ = art3d.rotate_axes(X, Y, Z, zdir) + cset = super().contour(jX, jY, jZ, *args, **kwargs) + self.add_contour_set(cset, extend3d, stride, zdir, offset, axlim_clip) + + self.auto_scale_xyz(X, Y, Z, had_data) + return cset + + contour3D = contour + + @_preprocess_data() + def tricontour(self, *args, + extend3d=False, stride=5, zdir='z', offset=None, axlim_clip=False, + **kwargs): + """ + Create a 3D contour plot. + + .. note:: + This method currently produces incorrect output due to a + longstanding bug in 3D PolyCollection rendering. + + Parameters + ---------- + X, Y, Z : array-like + Input data. See `.Axes.tricontour` for supported data shapes. + extend3d : bool, default: False + Whether to extend contour in 3D. + stride : int, default: 5 + Step size for extending contour. + zdir : {'x', 'y', 'z'}, default: 'z' + The direction to use. + offset : float, optional + If specified, plot a projection of the contour lines at this + position in a plane normal to *zdir*. + axlim_clip : bool, default: False + Whether to hide lines with a vertex outside the axes view limits. + + .. versionadded:: 3.10 + data : indexable object, optional + DATA_PARAMETER_PLACEHOLDER + *args, **kwargs + Other arguments are forwarded to `matplotlib.axes.Axes.tricontour`. + + Returns + ------- + matplotlib.tri._tricontour.TriContourSet + """ + had_data = self.has_data() + + tri, args, kwargs = Triangulation.get_from_args_and_kwargs( + *args, **kwargs) + X = tri.x + Y = tri.y + if 'Z' in kwargs: + Z = kwargs.pop('Z') + else: + # We do this so Z doesn't get passed as an arg to Axes.tricontour + Z, *args = args + + jX, jY, jZ = art3d.rotate_axes(X, Y, Z, zdir) + tri = Triangulation(jX, jY, tri.triangles, tri.mask) + + cset = super().tricontour(tri, jZ, *args, **kwargs) + self.add_contour_set(cset, extend3d, stride, zdir, offset, axlim_clip) + + self.auto_scale_xyz(X, Y, Z, had_data) + return cset + + def _auto_scale_contourf(self, X, Y, Z, zdir, levels, had_data): + # Autoscale in the zdir based on the levels added, which are + # different from data range if any contour extensions are present + dim_vals = {'x': X, 'y': Y, 'z': Z, zdir: levels} + # Input data and levels have different sizes, but auto_scale_xyz + # expected same-size input, so manually take min/max limits + limits = [(np.nanmin(dim_vals[dim]), np.nanmax(dim_vals[dim])) + for dim in ['x', 'y', 'z']] + self.auto_scale_xyz(*limits, had_data) + + @_preprocess_data() + def contourf(self, X, Y, Z, *args, + zdir='z', offset=None, axlim_clip=False, **kwargs): + """ + Create a 3D filled contour plot. + + Parameters + ---------- + X, Y, Z : array-like + Input data. See `.Axes.contourf` for supported data shapes. + zdir : {'x', 'y', 'z'}, default: 'z' + The direction to use. + offset : float, optional + If specified, plot a projection of the contour lines at this + position in a plane normal to *zdir*. + axlim_clip : bool, default: False + Whether to hide lines with a vertex outside the axes view limits. + + .. versionadded:: 3.10 + data : indexable object, optional + DATA_PARAMETER_PLACEHOLDER + *args, **kwargs + Other arguments are forwarded to `matplotlib.axes.Axes.contourf`. + + Returns + ------- + matplotlib.contour.QuadContourSet + """ + had_data = self.has_data() + + jX, jY, jZ = art3d.rotate_axes(X, Y, Z, zdir) + cset = super().contourf(jX, jY, jZ, *args, **kwargs) + levels = self._add_contourf_set(cset, zdir, offset, axlim_clip) + + self._auto_scale_contourf(X, Y, Z, zdir, levels, had_data) + return cset + + contourf3D = contourf + + @_preprocess_data() + def tricontourf(self, *args, zdir='z', offset=None, axlim_clip=False, **kwargs): + """ + Create a 3D filled contour plot. + + .. note:: + This method currently produces incorrect output due to a + longstanding bug in 3D PolyCollection rendering. + + Parameters + ---------- + X, Y, Z : array-like + Input data. See `.Axes.tricontourf` for supported data shapes. + zdir : {'x', 'y', 'z'}, default: 'z' + The direction to use. + offset : float, optional + If specified, plot a projection of the contour lines at this + position in a plane normal to zdir. + axlim_clip : bool, default: False + Whether to hide lines with a vertex outside the axes view limits. + + .. versionadded:: 3.10 + data : indexable object, optional + DATA_PARAMETER_PLACEHOLDER + *args, **kwargs + Other arguments are forwarded to + `matplotlib.axes.Axes.tricontourf`. + + Returns + ------- + matplotlib.tri._tricontour.TriContourSet + """ + had_data = self.has_data() + + tri, args, kwargs = Triangulation.get_from_args_and_kwargs( + *args, **kwargs) + X = tri.x + Y = tri.y + if 'Z' in kwargs: + Z = kwargs.pop('Z') + else: + # We do this so Z doesn't get passed as an arg to Axes.tricontourf + Z, *args = args + + jX, jY, jZ = art3d.rotate_axes(X, Y, Z, zdir) + tri = Triangulation(jX, jY, tri.triangles, tri.mask) + + cset = super().tricontourf(tri, jZ, *args, **kwargs) + levels = self._add_contourf_set(cset, zdir, offset, axlim_clip) + + self._auto_scale_contourf(X, Y, Z, zdir, levels, had_data) + return cset + + def add_collection3d(self, col, zs=0, zdir='z', autolim=True, *, + axlim_clip=False): + """ + Add a 3D collection object to the plot. + + 2D collection types are converted to a 3D version by + modifying the object and adding z coordinate information, + *zs* and *zdir*. + + Supported 2D collection types are: + + - `.PolyCollection` + - `.LineCollection` + - `.PatchCollection` (currently not supporting *autolim*) + + Parameters + ---------- + col : `.Collection` + A 2D collection object. + zs : float or array-like, default: 0 + The z-positions to be used for the 2D objects. + zdir : {'x', 'y', 'z'}, default: 'z' + The direction to use for the z-positions. + autolim : bool, default: True + Whether to update the data limits. + axlim_clip : bool, default: False + Whether to hide the scatter points outside the axes view limits. + + .. versionadded:: 3.10 + """ + had_data = self.has_data() + + zvals = np.atleast_1d(zs) + zsortval = (np.min(zvals) if zvals.size + else 0) # FIXME: arbitrary default + + # FIXME: use issubclass() (although, then a 3D collection + # object would also pass.) Maybe have a collection3d + # abstract class to test for and exclude? + if type(col) is mcoll.PolyCollection: + art3d.poly_collection_2d_to_3d(col, zs=zs, zdir=zdir, + axlim_clip=axlim_clip) + col.set_sort_zpos(zsortval) + elif type(col) is mcoll.LineCollection: + art3d.line_collection_2d_to_3d(col, zs=zs, zdir=zdir, + axlim_clip=axlim_clip) + col.set_sort_zpos(zsortval) + elif type(col) is mcoll.PatchCollection: + art3d.patch_collection_2d_to_3d(col, zs=zs, zdir=zdir, + axlim_clip=axlim_clip) + col.set_sort_zpos(zsortval) + + if autolim: + if isinstance(col, art3d.Line3DCollection): + self.auto_scale_xyz(*np.array(col._segments3d).transpose(), + had_data=had_data) + elif isinstance(col, art3d.Poly3DCollection): + self.auto_scale_xyz(*col._vec[:-1], had_data=had_data) + elif isinstance(col, art3d.Patch3DCollection): + pass + # FIXME: Implement auto-scaling function for Patch3DCollection + # Currently unable to do so due to issues with Patch3DCollection + # See https://github.com/matplotlib/matplotlib/issues/14298 for details + + collection = super().add_collection(col) + return collection + + @_preprocess_data(replace_names=["xs", "ys", "zs", "s", + "edgecolors", "c", "facecolor", + "facecolors", "color"]) + def scatter(self, xs, ys, + zs=0, zdir='z', s=20, c=None, depthshade=True, *args, + axlim_clip=False, **kwargs): + """ + Create a scatter plot. + + Parameters + ---------- + xs, ys : array-like + The data positions. + zs : float or array-like, default: 0 + The z-positions. Either an array of the same length as *xs* and + *ys* or a single value to place all points in the same plane. + zdir : {'x', 'y', 'z', '-x', '-y', '-z'}, default: 'z' + The axis direction for the *zs*. This is useful when plotting 2D + data on a 3D Axes. The data must be passed as *xs*, *ys*. Setting + *zdir* to 'y' then plots the data to the x-z-plane. + + See also :doc:`/gallery/mplot3d/2dcollections3d`. + + s : float or array-like, default: 20 + The marker size in points**2. Either an array of the same length + as *xs* and *ys* or a single value to make all markers the same + size. + c : :mpltype:`color`, sequence, or sequence of colors, optional + The marker color. Possible values: + + - A single color format string. + - A sequence of colors of length n. + - A sequence of n numbers to be mapped to colors using *cmap* and + *norm*. + - A 2D array in which the rows are RGB or RGBA. + + For more details see the *c* argument of `~.axes.Axes.scatter`. + depthshade : bool, default: True + Whether to shade the scatter markers to give the appearance of + depth. Each call to ``scatter()`` will perform its depthshading + independently. + axlim_clip : bool, default: False + Whether to hide the scatter points outside the axes view limits. + + .. versionadded:: 3.10 + data : indexable object, optional + DATA_PARAMETER_PLACEHOLDER + **kwargs + All other keyword arguments are passed on to `~.axes.Axes.scatter`. + + Returns + ------- + paths : `~matplotlib.collections.PathCollection` + """ + + had_data = self.has_data() + zs_orig = zs + + xs, ys, zs = cbook._broadcast_with_masks(xs, ys, zs) + s = np.ma.ravel(s) # This doesn't have to match x, y in size. + + xs, ys, zs, s, c, color = cbook.delete_masked_points( + xs, ys, zs, s, c, kwargs.get('color', None) + ) + if kwargs.get("color") is not None: + kwargs['color'] = color + + # For xs and ys, 2D scatter() will do the copying. + if np.may_share_memory(zs_orig, zs): # Avoid unnecessary copies. + zs = zs.copy() + + patches = super().scatter(xs, ys, s=s, c=c, *args, **kwargs) + art3d.patch_collection_2d_to_3d(patches, zs=zs, zdir=zdir, + depthshade=depthshade, + axlim_clip=axlim_clip) + + if self._zmargin < 0.05 and xs.size > 0: + self.set_zmargin(0.05) + + self.auto_scale_xyz(xs, ys, zs, had_data) + + return patches + + scatter3D = scatter + + @_preprocess_data() + def bar(self, left, height, zs=0, zdir='z', *args, + axlim_clip=False, **kwargs): + """ + Add 2D bar(s). + + Parameters + ---------- + left : 1D array-like + The x coordinates of the left sides of the bars. + height : 1D array-like + The height of the bars. + zs : float or 1D array-like, default: 0 + Z coordinate of bars; if a single value is specified, it will be + used for all bars. + zdir : {'x', 'y', 'z'}, default: 'z' + When plotting 2D data, the direction to use as z ('x', 'y' or 'z'). + axlim_clip : bool, default: False + Whether to hide bars with points outside the axes view limits. + + .. versionadded:: 3.10 + data : indexable object, optional + DATA_PARAMETER_PLACEHOLDER + **kwargs + Other keyword arguments are forwarded to + `matplotlib.axes.Axes.bar`. + + Returns + ------- + mpl_toolkits.mplot3d.art3d.Patch3DCollection + """ + had_data = self.has_data() + + patches = super().bar(left, height, *args, **kwargs) + + zs = np.broadcast_to(zs, len(left), subok=True) + + verts = [] + verts_zs = [] + for p, z in zip(patches, zs): + vs = art3d._get_patch_verts(p) + verts += vs.tolist() + verts_zs += [z] * len(vs) + art3d.patch_2d_to_3d(p, z, zdir, axlim_clip) + if 'alpha' in kwargs: + p.set_alpha(kwargs['alpha']) + + if len(verts) > 0: + # the following has to be skipped if verts is empty + # NOTE: Bugs could still occur if len(verts) > 0, + # but the "2nd dimension" is empty. + xs, ys = zip(*verts) + else: + xs, ys = [], [] + + xs, ys, verts_zs = art3d.juggle_axes(xs, ys, verts_zs, zdir) + self.auto_scale_xyz(xs, ys, verts_zs, had_data) + + return patches + + @_preprocess_data() + def bar3d(self, x, y, z, dx, dy, dz, color=None, + zsort='average', shade=True, lightsource=None, *args, + axlim_clip=False, **kwargs): + """ + Generate a 3D barplot. + + This method creates three-dimensional barplot where the width, + depth, height, and color of the bars can all be uniquely set. + + Parameters + ---------- + x, y, z : array-like + The coordinates of the anchor point of the bars. + + dx, dy, dz : float or array-like + The width, depth, and height of the bars, respectively. + + color : sequence of colors, optional + The color of the bars can be specified globally or + individually. This parameter can be: + + - A single color, to color all bars the same color. + - An array of colors of length N bars, to color each bar + independently. + - An array of colors of length 6, to color the faces of the + bars similarly. + - An array of colors of length 6 * N bars, to color each face + independently. + + When coloring the faces of the boxes specifically, this is + the order of the coloring: + + 1. -Z (bottom of box) + 2. +Z (top of box) + 3. -Y + 4. +Y + 5. -X + 6. +X + + zsort : {'average', 'min', 'max'}, default: 'average' + The z-axis sorting scheme passed onto `~.art3d.Poly3DCollection` + + shade : bool, default: True + When true, this shades the dark sides of the bars (relative + to the plot's source of light). + + lightsource : `~matplotlib.colors.LightSource`, optional + The lightsource to use when *shade* is True. + + axlim_clip : bool, default: False + Whether to hide the bars with points outside the axes view limits. + + .. versionadded:: 3.10 + + data : indexable object, optional + DATA_PARAMETER_PLACEHOLDER + + **kwargs + Any additional keyword arguments are passed onto + `~.art3d.Poly3DCollection`. + + Returns + ------- + collection : `~.art3d.Poly3DCollection` + A collection of three-dimensional polygons representing the bars. + """ + + had_data = self.has_data() + + x, y, z, dx, dy, dz = np.broadcast_arrays( + np.atleast_1d(x), y, z, dx, dy, dz) + minx = np.min(x) + maxx = np.max(x + dx) + miny = np.min(y) + maxy = np.max(y + dy) + minz = np.min(z) + maxz = np.max(z + dz) + + # shape (6, 4, 3) + # All faces are oriented facing outwards - when viewed from the + # outside, their vertices are in a counterclockwise ordering. + cuboid = np.array([ + # -z + ( + (0, 0, 0), + (0, 1, 0), + (1, 1, 0), + (1, 0, 0), + ), + # +z + ( + (0, 0, 1), + (1, 0, 1), + (1, 1, 1), + (0, 1, 1), + ), + # -y + ( + (0, 0, 0), + (1, 0, 0), + (1, 0, 1), + (0, 0, 1), + ), + # +y + ( + (0, 1, 0), + (0, 1, 1), + (1, 1, 1), + (1, 1, 0), + ), + # -x + ( + (0, 0, 0), + (0, 0, 1), + (0, 1, 1), + (0, 1, 0), + ), + # +x + ( + (1, 0, 0), + (1, 1, 0), + (1, 1, 1), + (1, 0, 1), + ), + ]) + + # indexed by [bar, face, vertex, coord] + polys = np.empty(x.shape + cuboid.shape) + + # handle each coordinate separately + for i, p, dp in [(0, x, dx), (1, y, dy), (2, z, dz)]: + p = p[..., np.newaxis, np.newaxis] + dp = dp[..., np.newaxis, np.newaxis] + polys[..., i] = p + dp * cuboid[..., i] + + # collapse the first two axes + polys = polys.reshape((-1,) + polys.shape[2:]) + + facecolors = [] + if color is None: + color = [self._get_patches_for_fill.get_next_color()] + + color = list(mcolors.to_rgba_array(color)) + + if len(color) == len(x): + # bar colors specified, need to expand to number of faces + for c in color: + facecolors.extend([c] * 6) + else: + # a single color specified, or face colors specified explicitly + facecolors = color + if len(facecolors) < len(x): + facecolors *= (6 * len(x)) + + col = art3d.Poly3DCollection(polys, + zsort=zsort, + facecolors=facecolors, + shade=shade, + lightsource=lightsource, + axlim_clip=axlim_clip, + *args, **kwargs) + self.add_collection(col) + + self.auto_scale_xyz((minx, maxx), (miny, maxy), (minz, maxz), had_data) + + return col + + def set_title(self, label, fontdict=None, loc='center', **kwargs): + # docstring inherited + ret = super().set_title(label, fontdict=fontdict, loc=loc, **kwargs) + (x, y) = self.title.get_position() + self.title.set_y(0.92 * y) + return ret + + @_preprocess_data() + def quiver(self, X, Y, Z, U, V, W, *, + length=1, arrow_length_ratio=.3, pivot='tail', normalize=False, + axlim_clip=False, **kwargs): + """ + Plot a 3D field of arrows. + + The arguments can be array-like or scalars, so long as they can be + broadcast together. The arguments can also be masked arrays. If an + element in any of argument is masked, then that corresponding quiver + element will not be plotted. + + Parameters + ---------- + X, Y, Z : array-like + The x, y and z coordinates of the arrow locations (default is + tail of arrow; see *pivot* kwarg). + + U, V, W : array-like + The x, y and z components of the arrow vectors. + + length : float, default: 1 + The length of each quiver. + + arrow_length_ratio : float, default: 0.3 + The ratio of the arrow head with respect to the quiver. + + pivot : {'tail', 'middle', 'tip'}, default: 'tail' + The part of the arrow that is at the grid point; the arrow + rotates about this point, hence the name *pivot*. + + normalize : bool, default: False + Whether all arrows are normalized to have the same length, or keep + the lengths defined by *u*, *v*, and *w*. + + axlim_clip : bool, default: False + Whether to hide arrows with points outside the axes view limits. + + .. versionadded:: 3.10 + + data : indexable object, optional + DATA_PARAMETER_PLACEHOLDER + + **kwargs + Any additional keyword arguments are delegated to + :class:`.Line3DCollection` + """ + + def calc_arrows(UVW): + # get unit direction vector perpendicular to (u, v, w) + x = UVW[:, 0] + y = UVW[:, 1] + norm = np.linalg.norm(UVW[:, :2], axis=1) + x_p = np.divide(y, norm, where=norm != 0, out=np.zeros_like(x)) + y_p = np.divide(-x, norm, where=norm != 0, out=np.ones_like(x)) + # compute the two arrowhead direction unit vectors + rangle = math.radians(15) + c = math.cos(rangle) + s = math.sin(rangle) + # construct the rotation matrices of shape (3, 3, n) + r13 = y_p * s + r32 = x_p * s + r12 = x_p * y_p * (1 - c) + Rpos = np.array( + [[c + (x_p ** 2) * (1 - c), r12, r13], + [r12, c + (y_p ** 2) * (1 - c), -r32], + [-r13, r32, np.full_like(x_p, c)]]) + # opposite rotation negates all the sin terms + Rneg = Rpos.copy() + Rneg[[0, 1, 2, 2], [2, 2, 0, 1]] *= -1 + # Batch n (3, 3) x (3) matrix multiplications ((3, 3, n) x (n, 3)). + Rpos_vecs = np.einsum("ij...,...j->...i", Rpos, UVW) + Rneg_vecs = np.einsum("ij...,...j->...i", Rneg, UVW) + # Stack into (n, 2, 3) result. + return np.stack([Rpos_vecs, Rneg_vecs], axis=1) + + had_data = self.has_data() + + input_args = cbook._broadcast_with_masks(X, Y, Z, U, V, W, + compress=True) + + if any(len(v) == 0 for v in input_args): + # No quivers, so just make an empty collection and return early + linec = art3d.Line3DCollection([], **kwargs) + self.add_collection(linec) + return linec + + shaft_dt = np.array([0., length], dtype=float) + arrow_dt = shaft_dt * arrow_length_ratio + + _api.check_in_list(['tail', 'middle', 'tip'], pivot=pivot) + if pivot == 'tail': + shaft_dt -= length + elif pivot == 'middle': + shaft_dt -= length / 2 + + XYZ = np.column_stack(input_args[:3]) + UVW = np.column_stack(input_args[3:]).astype(float) + + # Normalize rows of UVW + if normalize: + norm = np.linalg.norm(UVW, axis=1) + norm[norm == 0] = 1 + UVW = UVW / norm.reshape((-1, 1)) + + if len(XYZ) > 0: + # compute the shaft lines all at once with an outer product + shafts = (XYZ - np.multiply.outer(shaft_dt, UVW)).swapaxes(0, 1) + # compute head direction vectors, n heads x 2 sides x 3 dimensions + head_dirs = calc_arrows(UVW) + # compute all head lines at once, starting from the shaft ends + heads = shafts[:, :1] - np.multiply.outer(arrow_dt, head_dirs) + # stack left and right head lines together + heads = heads.reshape((len(arrow_dt), -1, 3)) + # transpose to get a list of lines + heads = heads.swapaxes(0, 1) + + lines = [*shafts, *heads[::2], *heads[1::2]] + else: + lines = [] + + linec = art3d.Line3DCollection(lines, axlim_clip=axlim_clip, **kwargs) + self.add_collection(linec) + + self.auto_scale_xyz(XYZ[:, 0], XYZ[:, 1], XYZ[:, 2], had_data) + + return linec + + quiver3D = quiver + + def voxels(self, *args, facecolors=None, edgecolors=None, shade=True, + lightsource=None, axlim_clip=False, **kwargs): + """ + ax.voxels([x, y, z,] /, filled, facecolors=None, edgecolors=None, \ +**kwargs) + + Plot a set of filled voxels + + All voxels are plotted as 1x1x1 cubes on the axis, with + ``filled[0, 0, 0]`` placed with its lower corner at the origin. + Occluded faces are not plotted. + + Parameters + ---------- + filled : 3D np.array of bool + A 3D array of values, with truthy values indicating which voxels + to fill + + x, y, z : 3D np.array, optional + The coordinates of the corners of the voxels. This should broadcast + to a shape one larger in every dimension than the shape of + *filled*. These can be used to plot non-cubic voxels. + + If not specified, defaults to increasing integers along each axis, + like those returned by :func:`~numpy.indices`. + As indicated by the ``/`` in the function signature, these + arguments can only be passed positionally. + + facecolors, edgecolors : array-like, optional + The color to draw the faces and edges of the voxels. Can only be + passed as keyword arguments. + These parameters can be: + + - A single color value, to color all voxels the same color. This + can be either a string, or a 1D RGB/RGBA array + - ``None``, the default, to use a single color for the faces, and + the style default for the edges. + - A 3D `~numpy.ndarray` of color names, with each item the color + for the corresponding voxel. The size must match the voxels. + - A 4D `~numpy.ndarray` of RGB/RGBA data, with the components + along the last axis. + + shade : bool, default: True + Whether to shade the facecolors. + + lightsource : `~matplotlib.colors.LightSource`, optional + The lightsource to use when *shade* is True. + + axlim_clip : bool, default: False + Whether to hide voxels with points outside the axes view limits. + + .. versionadded:: 3.10 + + **kwargs + Additional keyword arguments to pass onto + `~mpl_toolkits.mplot3d.art3d.Poly3DCollection`. + + Returns + ------- + faces : dict + A dictionary indexed by coordinate, where ``faces[i, j, k]`` is a + `.Poly3DCollection` of the faces drawn for the voxel + ``filled[i, j, k]``. If no faces were drawn for a given voxel, + either because it was not asked to be drawn, or it is fully + occluded, then ``(i, j, k) not in faces``. + + Examples + -------- + .. plot:: gallery/mplot3d/voxels.py + .. plot:: gallery/mplot3d/voxels_rgb.py + .. plot:: gallery/mplot3d/voxels_torus.py + .. plot:: gallery/mplot3d/voxels_numpy_logo.py + """ + + # work out which signature we should be using, and use it to parse + # the arguments. Name must be voxels for the correct error message + if len(args) >= 3: + # underscores indicate position only + def voxels(__x, __y, __z, filled, **kwargs): + return (__x, __y, __z), filled, kwargs + else: + def voxels(filled, **kwargs): + return None, filled, kwargs + + xyz, filled, kwargs = voxels(*args, **kwargs) + + # check dimensions + if filled.ndim != 3: + raise ValueError("Argument filled must be 3-dimensional") + size = np.array(filled.shape, dtype=np.intp) + + # check xyz coordinates, which are one larger than the filled shape + coord_shape = tuple(size + 1) + if xyz is None: + x, y, z = np.indices(coord_shape) + else: + x, y, z = (np.broadcast_to(c, coord_shape) for c in xyz) + + def _broadcast_color_arg(color, name): + if np.ndim(color) in (0, 1): + # single color, like "red" or [1, 0, 0] + return np.broadcast_to(color, filled.shape + np.shape(color)) + elif np.ndim(color) in (3, 4): + # 3D array of strings, or 4D array with last axis rgb + if np.shape(color)[:3] != filled.shape: + raise ValueError( + f"When multidimensional, {name} must match the shape " + "of filled") + return color + else: + raise ValueError(f"Invalid {name} argument") + + # broadcast and default on facecolors + if facecolors is None: + facecolors = self._get_patches_for_fill.get_next_color() + facecolors = _broadcast_color_arg(facecolors, 'facecolors') + + # broadcast but no default on edgecolors + edgecolors = _broadcast_color_arg(edgecolors, 'edgecolors') + + # scale to the full array, even if the data is only in the center + self.auto_scale_xyz(x, y, z) + + # points lying on corners of a square + square = np.array([ + [0, 0, 0], + [1, 0, 0], + [1, 1, 0], + [0, 1, 0], + ], dtype=np.intp) + + voxel_faces = defaultdict(list) + + def permutation_matrices(n): + """Generate cyclic permutation matrices.""" + mat = np.eye(n, dtype=np.intp) + for i in range(n): + yield mat + mat = np.roll(mat, 1, axis=0) + + # iterate over each of the YZ, ZX, and XY orientations, finding faces + # to render + for permute in permutation_matrices(3): + # find the set of ranges to iterate over + pc, qc, rc = permute.T.dot(size) + pinds = np.arange(pc) + qinds = np.arange(qc) + rinds = np.arange(rc) + + square_rot_pos = square.dot(permute.T) + square_rot_neg = square_rot_pos[::-1] + + # iterate within the current plane + for p in pinds: + for q in qinds: + # iterate perpendicularly to the current plane, handling + # boundaries. We only draw faces between a voxel and an + # empty space, to avoid drawing internal faces. + + # draw lower faces + p0 = permute.dot([p, q, 0]) + i0 = tuple(p0) + if filled[i0]: + voxel_faces[i0].append(p0 + square_rot_neg) + + # draw middle faces + for r1, r2 in itertools.pairwise(rinds): + p1 = permute.dot([p, q, r1]) + p2 = permute.dot([p, q, r2]) + + i1 = tuple(p1) + i2 = tuple(p2) + + if filled[i1] and not filled[i2]: + voxel_faces[i1].append(p2 + square_rot_pos) + elif not filled[i1] and filled[i2]: + voxel_faces[i2].append(p2 + square_rot_neg) + + # draw upper faces + pk = permute.dot([p, q, rc-1]) + pk2 = permute.dot([p, q, rc]) + ik = tuple(pk) + if filled[ik]: + voxel_faces[ik].append(pk2 + square_rot_pos) + + # iterate over the faces, and generate a Poly3DCollection for each + # voxel + polygons = {} + for coord, faces_inds in voxel_faces.items(): + # convert indices into 3D positions + if xyz is None: + faces = faces_inds + else: + faces = [] + for face_inds in faces_inds: + ind = face_inds[:, 0], face_inds[:, 1], face_inds[:, 2] + face = np.empty(face_inds.shape) + face[:, 0] = x[ind] + face[:, 1] = y[ind] + face[:, 2] = z[ind] + faces.append(face) + + # shade the faces + facecolor = facecolors[coord] + edgecolor = edgecolors[coord] + + poly = art3d.Poly3DCollection( + faces, facecolors=facecolor, edgecolors=edgecolor, + shade=shade, lightsource=lightsource, axlim_clip=axlim_clip, + **kwargs) + self.add_collection3d(poly) + polygons[coord] = poly + + return polygons + + @_preprocess_data(replace_names=["x", "y", "z", "xerr", "yerr", "zerr"]) + def errorbar(self, x, y, z, zerr=None, yerr=None, xerr=None, fmt='', + barsabove=False, errorevery=1, ecolor=None, elinewidth=None, + capsize=None, capthick=None, xlolims=False, xuplims=False, + ylolims=False, yuplims=False, zlolims=False, zuplims=False, + axlim_clip=False, + **kwargs): + """ + Plot lines and/or markers with errorbars around them. + + *x*/*y*/*z* define the data locations, and *xerr*/*yerr*/*zerr* define + the errorbar sizes. By default, this draws the data markers/lines as + well the errorbars. Use fmt='none' to draw errorbars only. + + Parameters + ---------- + x, y, z : float or array-like + The data positions. + + xerr, yerr, zerr : float or array-like, shape (N,) or (2, N), optional + The errorbar sizes: + + - scalar: Symmetric +/- values for all data points. + - shape(N,): Symmetric +/-values for each data point. + - shape(2, N): Separate - and + values for each bar. First row + contains the lower errors, the second row contains the upper + errors. + - *None*: No errorbar. + + Note that all error arrays should have *positive* values. + + fmt : str, default: '' + The format for the data points / data lines. See `.plot` for + details. + + Use 'none' (case-insensitive) to plot errorbars without any data + markers. + + ecolor : :mpltype:`color`, default: None + The color of the errorbar lines. If None, use the color of the + line connecting the markers. + + elinewidth : float, default: None + The linewidth of the errorbar lines. If None, the linewidth of + the current style is used. + + capsize : float, default: :rc:`errorbar.capsize` + The length of the error bar caps in points. + + capthick : float, default: None + An alias to the keyword argument *markeredgewidth* (a.k.a. *mew*). + This setting is a more sensible name for the property that + controls the thickness of the error bar cap in points. For + backwards compatibility, if *mew* or *markeredgewidth* are given, + then they will over-ride *capthick*. This may change in future + releases. + + barsabove : bool, default: False + If True, will plot the errorbars above the plot + symbols. Default is below. + + xlolims, ylolims, zlolims : bool, default: False + These arguments can be used to indicate that a value gives only + lower limits. In that case a caret symbol is used to indicate + this. *lims*-arguments may be scalars, or array-likes of the same + length as the errors. To use limits with inverted axes, + `~.set_xlim`, `~.set_ylim`, or `~.set_zlim` must be + called before `errorbar`. Note the tricky parameter names: setting + e.g. *ylolims* to True means that the y-value is a *lower* limit of + the True value, so, only an *upward*-pointing arrow will be drawn! + + xuplims, yuplims, zuplims : bool, default: False + Same as above, but for controlling the upper limits. + + errorevery : int or (int, int), default: 1 + draws error bars on a subset of the data. *errorevery* =N draws + error bars on the points (x[::N], y[::N], z[::N]). + *errorevery* =(start, N) draws error bars on the points + (x[start::N], y[start::N], z[start::N]). e.g. *errorevery* =(6, 3) + adds error bars to the data at (x[6], x[9], x[12], x[15], ...). + Used to avoid overlapping error bars when two series share x-axis + values. + + axlim_clip : bool, default: False + Whether to hide error bars that are outside the axes limits. + + .. versionadded:: 3.10 + + Returns + ------- + errlines : list + List of `~mpl_toolkits.mplot3d.art3d.Line3DCollection` instances + each containing an errorbar line. + caplines : list + List of `~mpl_toolkits.mplot3d.art3d.Line3D` instances each + containing a capline object. + limmarks : list + List of `~mpl_toolkits.mplot3d.art3d.Line3D` instances each + containing a marker with an upper or lower limit. + + Other Parameters + ---------------- + data : indexable object, optional + DATA_PARAMETER_PLACEHOLDER + + **kwargs + All other keyword arguments for styling errorbar lines are passed + `~mpl_toolkits.mplot3d.art3d.Line3DCollection`. + + Examples + -------- + .. plot:: gallery/mplot3d/errorbar3d.py + """ + had_data = self.has_data() + + kwargs = cbook.normalize_kwargs(kwargs, mlines.Line2D) + # Drop anything that comes in as None to use the default instead. + kwargs = {k: v for k, v in kwargs.items() if v is not None} + kwargs.setdefault('zorder', 2) + + self._process_unit_info([("x", x), ("y", y), ("z", z)], kwargs, + convert=False) + + # make sure all the args are iterable; use lists not arrays to + # preserve units + x = x if np.iterable(x) else [x] + y = y if np.iterable(y) else [y] + z = z if np.iterable(z) else [z] + + if not len(x) == len(y) == len(z): + raise ValueError("'x', 'y', and 'z' must have the same size") + + everymask = self._errorevery_to_mask(x, errorevery) + + label = kwargs.pop("label", None) + kwargs['label'] = '_nolegend_' + + # Create the main line and determine overall kwargs for child artists. + # We avoid calling self.plot() directly, or self._get_lines(), because + # that would call self._process_unit_info again, and do other indirect + # data processing. + (data_line, base_style), = self._get_lines._plot_args( + self, (x, y) if fmt == '' else (x, y, fmt), kwargs, return_kwargs=True) + art3d.line_2d_to_3d(data_line, zs=z, axlim_clip=axlim_clip) + + # Do this after creating `data_line` to avoid modifying `base_style`. + if barsabove: + data_line.set_zorder(kwargs['zorder'] - .1) + else: + data_line.set_zorder(kwargs['zorder'] + .1) + + # Add line to plot, or throw it away and use it to determine kwargs. + if fmt.lower() != 'none': + self.add_line(data_line) + else: + data_line = None + # Remove alpha=0 color that _process_plot_format returns. + base_style.pop('color') + + if 'color' not in base_style: + base_style['color'] = 'C0' + if ecolor is None: + ecolor = base_style['color'] + + # Eject any line-specific information from format string, as it's not + # needed for bars or caps. + for key in ['marker', 'markersize', 'markerfacecolor', + 'markeredgewidth', 'markeredgecolor', 'markevery', + 'linestyle', 'fillstyle', 'drawstyle', 'dash_capstyle', + 'dash_joinstyle', 'solid_capstyle', 'solid_joinstyle']: + base_style.pop(key, None) + + # Make the style dict for the line collections (the bars). + eb_lines_style = {**base_style, 'color': ecolor} + + if elinewidth: + eb_lines_style['linewidth'] = elinewidth + elif 'linewidth' in kwargs: + eb_lines_style['linewidth'] = kwargs['linewidth'] + + for key in ('transform', 'alpha', 'zorder', 'rasterized'): + if key in kwargs: + eb_lines_style[key] = kwargs[key] + + # Make the style dict for caps (the "hats"). + eb_cap_style = {**base_style, 'linestyle': 'None'} + if capsize is None: + capsize = mpl.rcParams["errorbar.capsize"] + if capsize > 0: + eb_cap_style['markersize'] = 2. * capsize + if capthick is not None: + eb_cap_style['markeredgewidth'] = capthick + eb_cap_style['color'] = ecolor + + def _apply_mask(arrays, mask): + # Return, for each array in *arrays*, the elements for which *mask* + # is True, without using fancy indexing. + return [[*itertools.compress(array, mask)] for array in arrays] + + def _extract_errs(err, data, lomask, himask): + # For separate +/- error values we need to unpack err + if len(err.shape) == 2: + low_err, high_err = err + else: + low_err, high_err = err, err + + lows = np.where(lomask | ~everymask, data, data - low_err) + highs = np.where(himask | ~everymask, data, data + high_err) + + return lows, highs + + # collect drawn items while looping over the three coordinates + errlines, caplines, limmarks = [], [], [] + + # list of endpoint coordinates, used for auto-scaling + coorderrs = [] + + # define the markers used for errorbar caps and limits below + # the dictionary key is mapped by the `i_xyz` helper dictionary + capmarker = {0: '|', 1: '|', 2: '_'} + i_xyz = {'x': 0, 'y': 1, 'z': 2} + + # Calculate marker size from points to quiver length. Because these are + # not markers, and 3D Axes do not use the normal transform stack, this + # is a bit involved. Since the quiver arrows will change size as the + # scene is rotated, they are given a standard size based on viewing + # them directly in planar form. + quiversize = eb_cap_style.get('markersize', + mpl.rcParams['lines.markersize']) ** 2 + quiversize *= self.get_figure(root=True).dpi / 72 + quiversize = self.transAxes.inverted().transform([ + (0, 0), (quiversize, quiversize)]) + quiversize = np.mean(np.diff(quiversize, axis=0)) + # quiversize is now in Axes coordinates, and to convert back to data + # coordinates, we need to run it through the inverse 3D transform. For + # consistency, this uses a fixed elevation, azimuth, and roll. + with cbook._setattr_cm(self, elev=0, azim=0, roll=0): + invM = np.linalg.inv(self.get_proj()) + # elev=azim=roll=0 produces the Y-Z plane, so quiversize in 2D 'x' is + # 'y' in 3D, hence the 1 index. + quiversize = np.dot(invM, [quiversize, 0, 0, 0])[1] + # Quivers use a fixed 15-degree arrow head, so scale up the length so + # that the size corresponds to the base. In other words, this constant + # corresponds to the equation tan(15) = (base / 2) / (arrow length). + quiversize *= 1.8660254037844388 + eb_quiver_style = {**eb_cap_style, + 'length': quiversize, 'arrow_length_ratio': 1} + eb_quiver_style.pop('markersize', None) + + # loop over x-, y-, and z-direction and draw relevant elements + for zdir, data, err, lolims, uplims in zip( + ['x', 'y', 'z'], [x, y, z], [xerr, yerr, zerr], + [xlolims, ylolims, zlolims], [xuplims, yuplims, zuplims]): + + dir_vector = art3d.get_dir_vector(zdir) + i_zdir = i_xyz[zdir] + + if err is None: + continue + + if not np.iterable(err): + err = [err] * len(data) + + err = np.atleast_1d(err) + + # arrays fine here, they are booleans and hence not units + lolims = np.broadcast_to(lolims, len(data)).astype(bool) + uplims = np.broadcast_to(uplims, len(data)).astype(bool) + + # a nested list structure that expands to (xl,xh),(yl,yh),(zl,zh), + # where x/y/z and l/h correspond to dimensions and low/high + # positions of errorbars in a dimension we're looping over + coorderr = [ + _extract_errs(err * dir_vector[i], coord, lolims, uplims) + for i, coord in enumerate([x, y, z])] + (xl, xh), (yl, yh), (zl, zh) = coorderr + + # draws capmarkers - flat caps orthogonal to the error bars + nolims = ~(lolims | uplims) + if nolims.any() and capsize > 0: + lo_caps_xyz = _apply_mask([xl, yl, zl], nolims & everymask) + hi_caps_xyz = _apply_mask([xh, yh, zh], nolims & everymask) + + # setting '_' for z-caps and '|' for x- and y-caps; + # these markers will rotate as the viewing angle changes + cap_lo = art3d.Line3D(*lo_caps_xyz, ls='', + marker=capmarker[i_zdir], + axlim_clip=axlim_clip, + **eb_cap_style) + cap_hi = art3d.Line3D(*hi_caps_xyz, ls='', + marker=capmarker[i_zdir], + axlim_clip=axlim_clip, + **eb_cap_style) + self.add_line(cap_lo) + self.add_line(cap_hi) + caplines.append(cap_lo) + caplines.append(cap_hi) + + if lolims.any(): + xh0, yh0, zh0 = _apply_mask([xh, yh, zh], lolims & everymask) + self.quiver(xh0, yh0, zh0, *dir_vector, **eb_quiver_style) + if uplims.any(): + xl0, yl0, zl0 = _apply_mask([xl, yl, zl], uplims & everymask) + self.quiver(xl0, yl0, zl0, *-dir_vector, **eb_quiver_style) + + errline = art3d.Line3DCollection(np.array(coorderr).T, + axlim_clip=axlim_clip, + **eb_lines_style) + self.add_collection(errline) + errlines.append(errline) + coorderrs.append(coorderr) + + coorderrs = np.array(coorderrs) + + def _digout_minmax(err_arr, coord_label): + return (np.nanmin(err_arr[:, i_xyz[coord_label], :, :]), + np.nanmax(err_arr[:, i_xyz[coord_label], :, :])) + + minx, maxx = _digout_minmax(coorderrs, 'x') + miny, maxy = _digout_minmax(coorderrs, 'y') + minz, maxz = _digout_minmax(coorderrs, 'z') + self.auto_scale_xyz((minx, maxx), (miny, maxy), (minz, maxz), had_data) + + # Adapting errorbar containers for 3d case, assuming z-axis points "up" + errorbar_container = mcontainer.ErrorbarContainer( + (data_line, tuple(caplines), tuple(errlines)), + has_xerr=(xerr is not None or yerr is not None), + has_yerr=(zerr is not None), + label=label) + self.containers.append(errorbar_container) + + return errlines, caplines, limmarks + + def get_tightbbox(self, renderer=None, *, call_axes_locator=True, + bbox_extra_artists=None, for_layout_only=False): + ret = super().get_tightbbox(renderer, + call_axes_locator=call_axes_locator, + bbox_extra_artists=bbox_extra_artists, + for_layout_only=for_layout_only) + batch = [ret] + if self._axis3don: + for axis in self._axis_map.values(): + if axis.get_visible(): + axis_bb = martist._get_tightbbox_for_layout_only( + axis, renderer) + if axis_bb: + batch.append(axis_bb) + return mtransforms.Bbox.union(batch) + + @_preprocess_data() + def stem(self, x, y, z, *, linefmt='C0-', markerfmt='C0o', basefmt='C3-', + bottom=0, label=None, orientation='z', axlim_clip=False): + """ + Create a 3D stem plot. + + A stem plot draws lines perpendicular to a baseline, and places markers + at the heads. By default, the baseline is defined by *x* and *y*, and + stems are drawn vertically from *bottom* to *z*. + + Parameters + ---------- + x, y, z : array-like + The positions of the heads of the stems. The stems are drawn along + the *orientation*-direction from the baseline at *bottom* (in the + *orientation*-coordinate) to the heads. By default, the *x* and *y* + positions are used for the baseline and *z* for the head position, + but this can be changed by *orientation*. + + linefmt : str, default: 'C0-' + A string defining the properties of the vertical lines. Usually, + this will be a color or a color and a linestyle: + + ========= ============= + Character Line Style + ========= ============= + ``'-'`` solid line + ``'--'`` dashed line + ``'-.'`` dash-dot line + ``':'`` dotted line + ========= ============= + + Note: While it is technically possible to specify valid formats + other than color or color and linestyle (e.g. 'rx' or '-.'), this + is beyond the intention of the method and will most likely not + result in a reasonable plot. + + markerfmt : str, default: 'C0o' + A string defining the properties of the markers at the stem heads. + + basefmt : str, default: 'C3-' + A format string defining the properties of the baseline. + + bottom : float, default: 0 + The position of the baseline, in *orientation*-coordinates. + + label : str, optional + The label to use for the stems in legends. + + orientation : {'x', 'y', 'z'}, default: 'z' + The direction along which stems are drawn. + + axlim_clip : bool, default: False + Whether to hide stems that are outside the axes limits. + + .. versionadded:: 3.10 + + data : indexable object, optional + DATA_PARAMETER_PLACEHOLDER + + Returns + ------- + `.StemContainer` + The container may be treated like a tuple + (*markerline*, *stemlines*, *baseline*) + + Examples + -------- + .. plot:: gallery/mplot3d/stem3d_demo.py + """ + + from matplotlib.container import StemContainer + + had_data = self.has_data() + + _api.check_in_list(['x', 'y', 'z'], orientation=orientation) + + xlim = (np.min(x), np.max(x)) + ylim = (np.min(y), np.max(y)) + zlim = (np.min(z), np.max(z)) + + # Determine the appropriate plane for the baseline and the direction of + # stemlines based on the value of orientation. + if orientation == 'x': + basex, basexlim = y, ylim + basey, baseylim = z, zlim + lines = [[(bottom, thisy, thisz), (thisx, thisy, thisz)] + for thisx, thisy, thisz in zip(x, y, z)] + elif orientation == 'y': + basex, basexlim = x, xlim + basey, baseylim = z, zlim + lines = [[(thisx, bottom, thisz), (thisx, thisy, thisz)] + for thisx, thisy, thisz in zip(x, y, z)] + else: + basex, basexlim = x, xlim + basey, baseylim = y, ylim + lines = [[(thisx, thisy, bottom), (thisx, thisy, thisz)] + for thisx, thisy, thisz in zip(x, y, z)] + + # Determine style for stem lines. + linestyle, linemarker, linecolor = _process_plot_format(linefmt) + if linestyle is None: + linestyle = mpl.rcParams['lines.linestyle'] + + # Plot everything in required order. + baseline, = self.plot(basex, basey, basefmt, zs=bottom, + zdir=orientation, label='_nolegend_') + stemlines = art3d.Line3DCollection( + lines, linestyles=linestyle, colors=linecolor, label='_nolegend_', + axlim_clip=axlim_clip) + self.add_collection(stemlines) + markerline, = self.plot(x, y, z, markerfmt, label='_nolegend_') + + stem_container = StemContainer((markerline, stemlines, baseline), + label=label) + self.add_container(stem_container) + + jx, jy, jz = art3d.juggle_axes(basexlim, baseylim, [bottom, bottom], + orientation) + self.auto_scale_xyz([*jx, *xlim], [*jy, *ylim], [*jz, *zlim], had_data) + + return stem_container + + stem3D = stem + + +def get_test_data(delta=0.05): + """Return a tuple X, Y, Z with a test data set.""" + x = y = np.arange(-3.0, 3.0, delta) + X, Y = np.meshgrid(x, y) + + Z1 = np.exp(-(X**2 + Y**2) / 2) / (2 * np.pi) + Z2 = (np.exp(-(((X - 1) / 1.5)**2 + ((Y - 1) / 0.5)**2) / 2) / + (2 * np.pi * 0.5 * 1.5)) + Z = Z2 - Z1 + + X = X * 10 + Y = Y * 10 + Z = Z * 500 + return X, Y, Z + + +class _Quaternion: + """ + Quaternions + consisting of scalar, along 1, and vector, with components along i, j, k + """ + + def __init__(self, scalar, vector): + self.scalar = scalar + self.vector = np.array(vector) + + def __neg__(self): + return self.__class__(-self.scalar, -self.vector) + + def __mul__(self, other): + """ + Product of two quaternions + i*i = j*j = k*k = i*j*k = -1 + Quaternion multiplication can be expressed concisely + using scalar and vector parts, + see <https://en.wikipedia.org/wiki/Quaternion#Scalar_and_vector_parts> + """ + return self.__class__( + self.scalar*other.scalar - np.dot(self.vector, other.vector), + self.scalar*other.vector + self.vector*other.scalar + + np.cross(self.vector, other.vector)) + + def conjugate(self): + """The conjugate quaternion -(1/2)*(q+i*q*i+j*q*j+k*q*k)""" + return self.__class__(self.scalar, -self.vector) + + @property + def norm(self): + """The 2-norm, q*q', a scalar""" + return self.scalar*self.scalar + np.dot(self.vector, self.vector) + + def normalize(self): + """Scaling such that norm equals 1""" + n = np.sqrt(self.norm) + return self.__class__(self.scalar/n, self.vector/n) + + def reciprocal(self): + """The reciprocal, 1/q = q'/(q*q') = q' / norm(q)""" + n = self.norm + return self.__class__(self.scalar/n, -self.vector/n) + + def __div__(self, other): + return self*other.reciprocal() + + __truediv__ = __div__ + + def rotate(self, v): + # Rotate the vector v by the quaternion q, i.e., + # calculate (the vector part of) q*v/q + v = self.__class__(0, v) + v = self*v/self + return v.vector + + def __eq__(self, other): + return (self.scalar == other.scalar) and (self.vector == other.vector).all + + def __repr__(self): + return "_Quaternion({}, {})".format(repr(self.scalar), repr(self.vector)) + + @classmethod + def rotate_from_to(cls, r1, r2): + """ + The quaternion for the shortest rotation from vector r1 to vector r2 + i.e., q = sqrt(r2*r1'), normalized. + If r1 and r2 are antiparallel, then the result is ambiguous; + a normal vector will be returned, and a warning will be issued. + """ + k = np.cross(r1, r2) + nk = np.linalg.norm(k) + th = np.arctan2(nk, np.dot(r1, r2)) + th /= 2 + if nk == 0: # r1 and r2 are parallel or anti-parallel + if np.dot(r1, r2) < 0: + warnings.warn("Rotation defined by anti-parallel vectors is ambiguous") + k = np.zeros(3) + k[np.argmin(r1*r1)] = 1 # basis vector most perpendicular to r1-r2 + k = np.cross(r1, k) + k = k / np.linalg.norm(k) # unit vector normal to r1-r2 + q = cls(0, k) + else: + q = cls(1, [0, 0, 0]) # = 1, no rotation + else: + q = cls(np.cos(th), k*np.sin(th)/nk) + return q + + @classmethod + def from_cardan_angles(cls, elev, azim, roll): + """ + Converts the angles to a quaternion + q = exp((roll/2)*e_x)*exp((elev/2)*e_y)*exp((-azim/2)*e_z) + i.e., the angles are a kind of Tait-Bryan angles, -z,y',x". + The angles should be given in radians, not degrees. + """ + ca, sa = np.cos(azim/2), np.sin(azim/2) + ce, se = np.cos(elev/2), np.sin(elev/2) + cr, sr = np.cos(roll/2), np.sin(roll/2) + + qw = ca*ce*cr + sa*se*sr + qx = ca*ce*sr - sa*se*cr + qy = ca*se*cr + sa*ce*sr + qz = ca*se*sr - sa*ce*cr + return cls(qw, [qx, qy, qz]) + + def as_cardan_angles(self): + """ + The inverse of `from_cardan_angles()`. + Note that the angles returned are in radians, not degrees. + The angles are not sensitive to the quaternion's norm(). + """ + qw = self.scalar + qx, qy, qz = self.vector[..., :] + azim = np.arctan2(2*(-qw*qz+qx*qy), qw*qw+qx*qx-qy*qy-qz*qz) + elev = np.arcsin(np.clip(2*(qw*qy+qz*qx)/(qw*qw+qx*qx+qy*qy+qz*qz), -1, 1)) + roll = np.arctan2(2*(qw*qx-qy*qz), qw*qw-qx*qx-qy*qy+qz*qz) + return elev, azim, roll diff --git a/lib/python3.12/site-packages/mpl_toolkits/mplot3d/axis3d.py b/lib/python3.12/site-packages/mpl_toolkits/mplot3d/axis3d.py new file mode 100644 index 0000000000000000000000000000000000000000..4da5031b990ca742eb0d303003af5c6a821c4db9 --- /dev/null +++ b/lib/python3.12/site-packages/mpl_toolkits/mplot3d/axis3d.py @@ -0,0 +1,750 @@ +# axis3d.py, original mplot3d version by John Porter +# Created: 23 Sep 2005 +# Parts rewritten by Reinier Heeres <reinier@heeres.eu> + +import inspect + +import numpy as np + +import matplotlib as mpl +from matplotlib import ( + _api, artist, lines as mlines, axis as maxis, patches as mpatches, + transforms as mtransforms, colors as mcolors) +from . import art3d, proj3d + + +def _move_from_center(coord, centers, deltas, axmask=(True, True, True)): + """ + For each coordinate where *axmask* is True, move *coord* away from + *centers* by *deltas*. + """ + coord = np.asarray(coord) + return coord + axmask * np.copysign(1, coord - centers) * deltas + + +def _tick_update_position(tick, tickxs, tickys, labelpos): + """Update tick line and label position and style.""" + + tick.label1.set_position(labelpos) + tick.label2.set_position(labelpos) + tick.tick1line.set_visible(True) + tick.tick2line.set_visible(False) + tick.tick1line.set_linestyle('-') + tick.tick1line.set_marker('') + tick.tick1line.set_data(tickxs, tickys) + tick.gridline.set_data([0], [0]) + + +class Axis(maxis.XAxis): + """An Axis class for the 3D plots.""" + # These points from the unit cube make up the x, y and z-planes + _PLANES = ( + (0, 3, 7, 4), (1, 2, 6, 5), # yz planes + (0, 1, 5, 4), (3, 2, 6, 7), # xz planes + (0, 1, 2, 3), (4, 5, 6, 7), # xy planes + ) + + # Some properties for the axes + _AXINFO = { + 'x': {'i': 0, 'tickdir': 1, 'juggled': (1, 0, 2)}, + 'y': {'i': 1, 'tickdir': 0, 'juggled': (0, 1, 2)}, + 'z': {'i': 2, 'tickdir': 0, 'juggled': (0, 2, 1)}, + } + + def _old_init(self, adir, v_intervalx, d_intervalx, axes, *args, + rotate_label=None, **kwargs): + return locals() + + def _new_init(self, axes, *, rotate_label=None, **kwargs): + return locals() + + def __init__(self, *args, **kwargs): + params = _api.select_matching_signature( + [self._old_init, self._new_init], *args, **kwargs) + if "adir" in params: + _api.warn_deprecated( + "3.6", message=f"The signature of 3D Axis constructors has " + f"changed in %(since)s; the new signature is " + f"{inspect.signature(type(self).__init__)}", pending=True) + if params["adir"] != self.axis_name: + raise ValueError(f"Cannot instantiate {type(self).__name__} " + f"with adir={params['adir']!r}") + axes = params["axes"] + rotate_label = params["rotate_label"] + args = params.get("args", ()) + kwargs = params["kwargs"] + + name = self.axis_name + + self._label_position = 'default' + self._tick_position = 'default' + + # This is a temporary member variable. + # Do not depend on this existing in future releases! + self._axinfo = self._AXINFO[name].copy() + # Common parts + self._axinfo.update({ + 'label': {'va': 'center', 'ha': 'center', + 'rotation_mode': 'anchor'}, + 'color': mpl.rcParams[f'axes3d.{name}axis.panecolor'], + 'tick': { + 'inward_factor': 0.2, + 'outward_factor': 0.1, + }, + }) + + if mpl.rcParams['_internal.classic_mode']: + self._axinfo.update({ + 'axisline': {'linewidth': 0.75, 'color': (0, 0, 0, 1)}, + 'grid': { + 'color': (0.9, 0.9, 0.9, 1), + 'linewidth': 1.0, + 'linestyle': '-', + }, + }) + self._axinfo['tick'].update({ + 'linewidth': { + True: mpl.rcParams['lines.linewidth'], # major + False: mpl.rcParams['lines.linewidth'], # minor + } + }) + else: + self._axinfo.update({ + 'axisline': { + 'linewidth': mpl.rcParams['axes.linewidth'], + 'color': mpl.rcParams['axes.edgecolor'], + }, + 'grid': { + 'color': mpl.rcParams['grid.color'], + 'linewidth': mpl.rcParams['grid.linewidth'], + 'linestyle': mpl.rcParams['grid.linestyle'], + }, + }) + self._axinfo['tick'].update({ + 'linewidth': { + True: ( # major + mpl.rcParams['xtick.major.width'] if name in 'xz' + else mpl.rcParams['ytick.major.width']), + False: ( # minor + mpl.rcParams['xtick.minor.width'] if name in 'xz' + else mpl.rcParams['ytick.minor.width']), + } + }) + + super().__init__(axes, *args, **kwargs) + + # data and viewing intervals for this direction + if "d_intervalx" in params: + self.set_data_interval(*params["d_intervalx"]) + if "v_intervalx" in params: + self.set_view_interval(*params["v_intervalx"]) + self.set_rotate_label(rotate_label) + self._init3d() # Inline after init3d deprecation elapses. + + __init__.__signature__ = inspect.signature(_new_init) + adir = _api.deprecated("3.6", pending=True)( + property(lambda self: self.axis_name)) + + def _init3d(self): + self.line = mlines.Line2D( + xdata=(0, 0), ydata=(0, 0), + linewidth=self._axinfo['axisline']['linewidth'], + color=self._axinfo['axisline']['color'], + antialiased=True) + + # Store dummy data in Polygon object + self.pane = mpatches.Polygon([[0, 0], [0, 1]], closed=False) + self.set_pane_color(self._axinfo['color']) + + self.axes._set_artist_props(self.line) + self.axes._set_artist_props(self.pane) + self.gridlines = art3d.Line3DCollection([]) + self.axes._set_artist_props(self.gridlines) + self.axes._set_artist_props(self.label) + self.axes._set_artist_props(self.offsetText) + # Need to be able to place the label at the correct location + self.label._transform = self.axes.transData + self.offsetText._transform = self.axes.transData + + @_api.deprecated("3.6", pending=True) + def init3d(self): # After deprecation elapses, inline _init3d to __init__. + self._init3d() + + def get_major_ticks(self, numticks=None): + ticks = super().get_major_ticks(numticks) + for t in ticks: + for obj in [ + t.tick1line, t.tick2line, t.gridline, t.label1, t.label2]: + obj.set_transform(self.axes.transData) + return ticks + + def get_minor_ticks(self, numticks=None): + ticks = super().get_minor_ticks(numticks) + for t in ticks: + for obj in [ + t.tick1line, t.tick2line, t.gridline, t.label1, t.label2]: + obj.set_transform(self.axes.transData) + return ticks + + def set_ticks_position(self, position): + """ + Set the ticks position. + + Parameters + ---------- + position : {'lower', 'upper', 'both', 'default', 'none'} + The position of the bolded axis lines, ticks, and tick labels. + """ + _api.check_in_list(['lower', 'upper', 'both', 'default', 'none'], + position=position) + self._tick_position = position + + def get_ticks_position(self): + """ + Get the ticks position. + + Returns + ------- + str : {'lower', 'upper', 'both', 'default', 'none'} + The position of the bolded axis lines, ticks, and tick labels. + """ + return self._tick_position + + def set_label_position(self, position): + """ + Set the label position. + + Parameters + ---------- + position : {'lower', 'upper', 'both', 'default', 'none'} + The position of the axis label. + """ + _api.check_in_list(['lower', 'upper', 'both', 'default', 'none'], + position=position) + self._label_position = position + + def get_label_position(self): + """ + Get the label position. + + Returns + ------- + str : {'lower', 'upper', 'both', 'default', 'none'} + The position of the axis label. + """ + return self._label_position + + def set_pane_color(self, color, alpha=None): + """ + Set pane color. + + Parameters + ---------- + color : :mpltype:`color` + Color for axis pane. + alpha : float, optional + Alpha value for axis pane. If None, base it on *color*. + """ + color = mcolors.to_rgba(color, alpha) + self._axinfo['color'] = color + self.pane.set_edgecolor(color) + self.pane.set_facecolor(color) + self.pane.set_alpha(color[-1]) + self.stale = True + + def set_rotate_label(self, val): + """ + Whether to rotate the axis label: True, False or None. + If set to None the label will be rotated if longer than 4 chars. + """ + self._rotate_label = val + self.stale = True + + def get_rotate_label(self, text): + if self._rotate_label is not None: + return self._rotate_label + else: + return len(text) > 4 + + def _get_coord_info(self): + mins, maxs = np.array([ + self.axes.get_xbound(), + self.axes.get_ybound(), + self.axes.get_zbound(), + ]).T + + # Project the bounds along the current position of the cube: + bounds = mins[0], maxs[0], mins[1], maxs[1], mins[2], maxs[2] + bounds_proj = self.axes._transformed_cube(bounds) + + # Determine which one of the parallel planes are higher up: + means_z0 = np.zeros(3) + means_z1 = np.zeros(3) + for i in range(3): + means_z0[i] = np.mean(bounds_proj[self._PLANES[2 * i], 2]) + means_z1[i] = np.mean(bounds_proj[self._PLANES[2 * i + 1], 2]) + highs = means_z0 < means_z1 + + # Special handling for edge-on views + equals = np.abs(means_z0 - means_z1) <= np.finfo(float).eps + if np.sum(equals) == 2: + vertical = np.where(~equals)[0][0] + if vertical == 2: # looking at XY plane + highs = np.array([True, True, highs[2]]) + elif vertical == 1: # looking at XZ plane + highs = np.array([True, highs[1], False]) + elif vertical == 0: # looking at YZ plane + highs = np.array([highs[0], False, False]) + + return mins, maxs, bounds_proj, highs + + def _calc_centers_deltas(self, maxs, mins): + centers = 0.5 * (maxs + mins) + # In mpl3.8, the scale factor was 1/12. mpl3.9 changes this to + # 1/12 * 24/25 = 0.08 to compensate for the change in automargin + # behavior and keep appearance the same. The 24/25 factor is from the + # 1/48 padding added to each side of the axis in mpl3.8. + scale = 0.08 + deltas = (maxs - mins) * scale + return centers, deltas + + def _get_axis_line_edge_points(self, minmax, maxmin, position=None): + """Get the edge points for the black bolded axis line.""" + # When changing vertical axis some of the axes has to be + # moved to the other plane so it looks the same as if the z-axis + # was the vertical axis. + mb = [minmax, maxmin] # line from origin to nearest corner to camera + mb_rev = mb[::-1] + mm = [[mb, mb_rev, mb_rev], [mb_rev, mb_rev, mb], [mb, mb, mb]] + mm = mm[self.axes._vertical_axis][self._axinfo["i"]] + + juggled = self._axinfo["juggled"] + edge_point_0 = mm[0].copy() # origin point + + if ((position == 'lower' and mm[1][juggled[-1]] < mm[0][juggled[-1]]) or + (position == 'upper' and mm[1][juggled[-1]] > mm[0][juggled[-1]])): + edge_point_0[juggled[-1]] = mm[1][juggled[-1]] + else: + edge_point_0[juggled[0]] = mm[1][juggled[0]] + + edge_point_1 = edge_point_0.copy() + edge_point_1[juggled[1]] = mm[1][juggled[1]] + + return edge_point_0, edge_point_1 + + def _get_all_axis_line_edge_points(self, minmax, maxmin, axis_position=None): + # Determine edge points for the axis lines + edgep1s = [] + edgep2s = [] + position = [] + if axis_position in (None, 'default'): + edgep1, edgep2 = self._get_axis_line_edge_points(minmax, maxmin) + edgep1s = [edgep1] + edgep2s = [edgep2] + position = ['default'] + else: + edgep1_l, edgep2_l = self._get_axis_line_edge_points(minmax, maxmin, + position='lower') + edgep1_u, edgep2_u = self._get_axis_line_edge_points(minmax, maxmin, + position='upper') + if axis_position in ('lower', 'both'): + edgep1s.append(edgep1_l) + edgep2s.append(edgep2_l) + position.append('lower') + if axis_position in ('upper', 'both'): + edgep1s.append(edgep1_u) + edgep2s.append(edgep2_u) + position.append('upper') + return edgep1s, edgep2s, position + + def _get_tickdir(self, position): + """ + Get the direction of the tick. + + Parameters + ---------- + position : str, optional : {'upper', 'lower', 'default'} + The position of the axis. + + Returns + ------- + tickdir : int + Index which indicates which coordinate the tick line will + align with. + """ + _api.check_in_list(('upper', 'lower', 'default'), position=position) + + # TODO: Move somewhere else where it's triggered less: + tickdirs_base = [v["tickdir"] for v in self._AXINFO.values()] # default + elev_mod = np.mod(self.axes.elev + 180, 360) - 180 + azim_mod = np.mod(self.axes.azim, 360) + if position == 'upper': + if elev_mod >= 0: + tickdirs_base = [2, 2, 0] + else: + tickdirs_base = [1, 0, 0] + if 0 <= azim_mod < 180: + tickdirs_base[2] = 1 + elif position == 'lower': + if elev_mod >= 0: + tickdirs_base = [1, 0, 1] + else: + tickdirs_base = [2, 2, 1] + if 0 <= azim_mod < 180: + tickdirs_base[2] = 0 + info_i = [v["i"] for v in self._AXINFO.values()] + + i = self._axinfo["i"] + vert_ax = self.axes._vertical_axis + j = vert_ax - 2 + # default: tickdir = [[1, 2, 1], [2, 2, 0], [1, 0, 0]][vert_ax][i] + tickdir = np.roll(info_i, -j)[np.roll(tickdirs_base, j)][i] + return tickdir + + def active_pane(self): + mins, maxs, tc, highs = self._get_coord_info() + info = self._axinfo + index = info['i'] + if not highs[index]: + loc = mins[index] + plane = self._PLANES[2 * index] + else: + loc = maxs[index] + plane = self._PLANES[2 * index + 1] + xys = np.array([tc[p] for p in plane]) + return xys, loc + + def draw_pane(self, renderer): + """ + Draw pane. + + Parameters + ---------- + renderer : `~matplotlib.backend_bases.RendererBase` subclass + """ + renderer.open_group('pane3d', gid=self.get_gid()) + xys, loc = self.active_pane() + self.pane.xy = xys[:, :2] + self.pane.draw(renderer) + renderer.close_group('pane3d') + + def _axmask(self): + axmask = [True, True, True] + axmask[self._axinfo["i"]] = False + return axmask + + def _draw_ticks(self, renderer, edgep1, centers, deltas, highs, + deltas_per_point, pos): + ticks = self._update_ticks() + info = self._axinfo + index = info["i"] + juggled = info["juggled"] + + mins, maxs, tc, highs = self._get_coord_info() + centers, deltas = self._calc_centers_deltas(maxs, mins) + + # Draw ticks: + tickdir = self._get_tickdir(pos) + tickdelta = deltas[tickdir] if highs[tickdir] else -deltas[tickdir] + + tick_info = info['tick'] + tick_out = tick_info['outward_factor'] * tickdelta + tick_in = tick_info['inward_factor'] * tickdelta + tick_lw = tick_info['linewidth'] + edgep1_tickdir = edgep1[tickdir] + out_tickdir = edgep1_tickdir + tick_out + in_tickdir = edgep1_tickdir - tick_in + + default_label_offset = 8. # A rough estimate + points = deltas_per_point * deltas + for tick in ticks: + # Get tick line positions + pos = edgep1.copy() + pos[index] = tick.get_loc() + pos[tickdir] = out_tickdir + x1, y1, z1 = proj3d.proj_transform(*pos, self.axes.M) + pos[tickdir] = in_tickdir + x2, y2, z2 = proj3d.proj_transform(*pos, self.axes.M) + + # Get position of label + labeldeltas = (tick.get_pad() + default_label_offset) * points + + pos[tickdir] = edgep1_tickdir + pos = _move_from_center(pos, centers, labeldeltas, self._axmask()) + lx, ly, lz = proj3d.proj_transform(*pos, self.axes.M) + + _tick_update_position(tick, (x1, x2), (y1, y2), (lx, ly)) + tick.tick1line.set_linewidth(tick_lw[tick._major]) + tick.draw(renderer) + + def _draw_offset_text(self, renderer, edgep1, edgep2, labeldeltas, centers, + highs, pep, dx, dy): + # Get general axis information: + info = self._axinfo + index = info["i"] + juggled = info["juggled"] + tickdir = info["tickdir"] + + # Which of the two edge points do we want to + # use for locating the offset text? + if juggled[2] == 2: + outeredgep = edgep1 + outerindex = 0 + else: + outeredgep = edgep2 + outerindex = 1 + + pos = _move_from_center(outeredgep, centers, labeldeltas, + self._axmask()) + olx, oly, olz = proj3d.proj_transform(*pos, self.axes.M) + self.offsetText.set_text(self.major.formatter.get_offset()) + self.offsetText.set_position((olx, oly)) + angle = art3d._norm_text_angle(np.rad2deg(np.arctan2(dy, dx))) + self.offsetText.set_rotation(angle) + # Must set rotation mode to "anchor" so that + # the alignment point is used as the "fulcrum" for rotation. + self.offsetText.set_rotation_mode('anchor') + + # ---------------------------------------------------------------------- + # Note: the following statement for determining the proper alignment of + # the offset text. This was determined entirely by trial-and-error + # and should not be in any way considered as "the way". There are + # still some edge cases where alignment is not quite right, but this + # seems to be more of a geometry issue (in other words, I might be + # using the wrong reference points). + # + # (TT, FF, TF, FT) are the shorthand for the tuple of + # (centpt[tickdir] <= pep[tickdir, outerindex], + # centpt[index] <= pep[index, outerindex]) + # + # Three-letters (e.g., TFT, FTT) are short-hand for the array of bools + # from the variable 'highs'. + # --------------------------------------------------------------------- + centpt = proj3d.proj_transform(*centers, self.axes.M) + if centpt[tickdir] > pep[tickdir, outerindex]: + # if FT and if highs has an even number of Trues + if (centpt[index] <= pep[index, outerindex] + and np.count_nonzero(highs) % 2 == 0): + # Usually, this means align right, except for the FTT case, + # in which offset for axis 1 and 2 are aligned left. + if highs.tolist() == [False, True, True] and index in (1, 2): + align = 'left' + else: + align = 'right' + else: + # The FF case + align = 'left' + else: + # if TF and if highs has an even number of Trues + if (centpt[index] > pep[index, outerindex] + and np.count_nonzero(highs) % 2 == 0): + # Usually mean align left, except if it is axis 2 + align = 'right' if index == 2 else 'left' + else: + # The TT case + align = 'right' + + self.offsetText.set_va('center') + self.offsetText.set_ha(align) + self.offsetText.draw(renderer) + + def _draw_labels(self, renderer, edgep1, edgep2, labeldeltas, centers, dx, dy): + label = self._axinfo["label"] + + # Draw labels + lxyz = 0.5 * (edgep1 + edgep2) + lxyz = _move_from_center(lxyz, centers, labeldeltas, self._axmask()) + tlx, tly, tlz = proj3d.proj_transform(*lxyz, self.axes.M) + self.label.set_position((tlx, tly)) + if self.get_rotate_label(self.label.get_text()): + angle = art3d._norm_text_angle(np.rad2deg(np.arctan2(dy, dx))) + self.label.set_rotation(angle) + self.label.set_va(label['va']) + self.label.set_ha(label['ha']) + self.label.set_rotation_mode(label['rotation_mode']) + self.label.draw(renderer) + + @artist.allow_rasterization + def draw(self, renderer): + self.label._transform = self.axes.transData + self.offsetText._transform = self.axes.transData + renderer.open_group("axis3d", gid=self.get_gid()) + + # Get general axis information: + mins, maxs, tc, highs = self._get_coord_info() + centers, deltas = self._calc_centers_deltas(maxs, mins) + + # Calculate offset distances + # A rough estimate; points are ambiguous since 3D plots rotate + reltoinches = self.get_figure(root=False).dpi_scale_trans.inverted() + ax_inches = reltoinches.transform(self.axes.bbox.size) + ax_points_estimate = sum(72. * ax_inches) + deltas_per_point = 48 / ax_points_estimate + default_offset = 21. + labeldeltas = (self.labelpad + default_offset) * deltas_per_point * deltas + + # Determine edge points for the axis lines + minmax = np.where(highs, maxs, mins) # "origin" point + maxmin = np.where(~highs, maxs, mins) # "opposite" corner near camera + + for edgep1, edgep2, pos in zip(*self._get_all_axis_line_edge_points( + minmax, maxmin, self._tick_position)): + # Project the edge points along the current position + pep = proj3d._proj_trans_points([edgep1, edgep2], self.axes.M) + pep = np.asarray(pep) + + # The transAxes transform is used because the Text object + # rotates the text relative to the display coordinate system. + # Therefore, if we want the labels to remain parallel to the + # axis regardless of the aspect ratio, we need to convert the + # edge points of the plane to display coordinates and calculate + # an angle from that. + # TODO: Maybe Text objects should handle this themselves? + dx, dy = (self.axes.transAxes.transform([pep[0:2, 1]]) - + self.axes.transAxes.transform([pep[0:2, 0]]))[0] + + # Draw the lines + self.line.set_data(pep[0], pep[1]) + self.line.draw(renderer) + + # Draw ticks + self._draw_ticks(renderer, edgep1, centers, deltas, highs, + deltas_per_point, pos) + + # Draw Offset text + self._draw_offset_text(renderer, edgep1, edgep2, labeldeltas, + centers, highs, pep, dx, dy) + + for edgep1, edgep2, pos in zip(*self._get_all_axis_line_edge_points( + minmax, maxmin, self._label_position)): + # See comments above + pep = proj3d._proj_trans_points([edgep1, edgep2], self.axes.M) + pep = np.asarray(pep) + dx, dy = (self.axes.transAxes.transform([pep[0:2, 1]]) - + self.axes.transAxes.transform([pep[0:2, 0]]))[0] + + # Draw labels + self._draw_labels(renderer, edgep1, edgep2, labeldeltas, centers, dx, dy) + + renderer.close_group('axis3d') + self.stale = False + + @artist.allow_rasterization + def draw_grid(self, renderer): + if not self.axes._draw_grid: + return + + renderer.open_group("grid3d", gid=self.get_gid()) + + ticks = self._update_ticks() + if len(ticks): + # Get general axis information: + info = self._axinfo + index = info["i"] + + mins, maxs, tc, highs = self._get_coord_info() + + minmax = np.where(highs, maxs, mins) + maxmin = np.where(~highs, maxs, mins) + + # Grid points where the planes meet + xyz0 = np.tile(minmax, (len(ticks), 1)) + xyz0[:, index] = [tick.get_loc() for tick in ticks] + + # Grid lines go from the end of one plane through the plane + # intersection (at xyz0) to the end of the other plane. The first + # point (0) differs along dimension index-2 and the last (2) along + # dimension index-1. + lines = np.stack([xyz0, xyz0, xyz0], axis=1) + lines[:, 0, index - 2] = maxmin[index - 2] + lines[:, 2, index - 1] = maxmin[index - 1] + self.gridlines.set_segments(lines) + gridinfo = info['grid'] + self.gridlines.set_color(gridinfo['color']) + self.gridlines.set_linewidth(gridinfo['linewidth']) + self.gridlines.set_linestyle(gridinfo['linestyle']) + self.gridlines.do_3d_projection() + self.gridlines.draw(renderer) + + renderer.close_group('grid3d') + + # TODO: Get this to work (more) properly when mplot3d supports the + # transforms framework. + def get_tightbbox(self, renderer=None, *, for_layout_only=False): + # docstring inherited + if not self.get_visible(): + return + # We have to directly access the internal data structures + # (and hope they are up to date) because at draw time we + # shift the ticks and their labels around in (x, y) space + # based on the projection, the current view port, and their + # position in 3D space. If we extend the transforms framework + # into 3D we would not need to do this different book keeping + # than we do in the normal axis + major_locs = self.get_majorticklocs() + minor_locs = self.get_minorticklocs() + + ticks = [*self.get_minor_ticks(len(minor_locs)), + *self.get_major_ticks(len(major_locs))] + view_low, view_high = self.get_view_interval() + if view_low > view_high: + view_low, view_high = view_high, view_low + interval_t = self.get_transform().transform([view_low, view_high]) + + ticks_to_draw = [] + for tick in ticks: + try: + loc_t = self.get_transform().transform(tick.get_loc()) + except AssertionError: + # Transform.transform doesn't allow masked values but + # some scales might make them, so we need this try/except. + pass + else: + if mtransforms._interval_contains_close(interval_t, loc_t): + ticks_to_draw.append(tick) + + ticks = ticks_to_draw + + bb_1, bb_2 = self._get_ticklabel_bboxes(ticks, renderer) + other = [] + + if self.line.get_visible(): + other.append(self.line.get_window_extent(renderer)) + if (self.label.get_visible() and not for_layout_only and + self.label.get_text()): + other.append(self.label.get_window_extent(renderer)) + + return mtransforms.Bbox.union([*bb_1, *bb_2, *other]) + + d_interval = _api.deprecated( + "3.6", alternative="get_data_interval", pending=True)( + property(lambda self: self.get_data_interval(), + lambda self, minmax: self.set_data_interval(*minmax))) + v_interval = _api.deprecated( + "3.6", alternative="get_view_interval", pending=True)( + property(lambda self: self.get_view_interval(), + lambda self, minmax: self.set_view_interval(*minmax))) + + +class XAxis(Axis): + axis_name = "x" + get_view_interval, set_view_interval = maxis._make_getset_interval( + "view", "xy_viewLim", "intervalx") + get_data_interval, set_data_interval = maxis._make_getset_interval( + "data", "xy_dataLim", "intervalx") + + +class YAxis(Axis): + axis_name = "y" + get_view_interval, set_view_interval = maxis._make_getset_interval( + "view", "xy_viewLim", "intervaly") + get_data_interval, set_data_interval = maxis._make_getset_interval( + "data", "xy_dataLim", "intervaly") + + +class ZAxis(Axis): + axis_name = "z" + get_view_interval, set_view_interval = maxis._make_getset_interval( + "view", "zz_viewLim", "intervalx") + get_data_interval, set_data_interval = maxis._make_getset_interval( + "data", "zz_dataLim", "intervalx") diff --git a/lib/python3.12/site-packages/mpl_toolkits/mplot3d/proj3d.py b/lib/python3.12/site-packages/mpl_toolkits/mplot3d/proj3d.py new file mode 100644 index 0000000000000000000000000000000000000000..923bd32c9ce032ad27379f8d8c6436564ec16d75 --- /dev/null +++ b/lib/python3.12/site-packages/mpl_toolkits/mplot3d/proj3d.py @@ -0,0 +1,219 @@ +""" +Various transforms used for by the 3D code +""" + +import numpy as np + +from matplotlib import _api + + +def world_transformation(xmin, xmax, + ymin, ymax, + zmin, zmax, pb_aspect=None): + """ + Produce a matrix that scales homogeneous coords in the specified ranges + to [0, 1], or [0, pb_aspect[i]] if the plotbox aspect ratio is specified. + """ + dx = xmax - xmin + dy = ymax - ymin + dz = zmax - zmin + if pb_aspect is not None: + ax, ay, az = pb_aspect + dx /= ax + dy /= ay + dz /= az + + return np.array([[1/dx, 0, 0, -xmin/dx], + [ 0, 1/dy, 0, -ymin/dy], + [ 0, 0, 1/dz, -zmin/dz], + [ 0, 0, 0, 1]]) + + +def _rotation_about_vector(v, angle): + """ + Produce a rotation matrix for an angle in radians about a vector. + """ + vx, vy, vz = v / np.linalg.norm(v) + s = np.sin(angle) + c = np.cos(angle) + t = 2*np.sin(angle/2)**2 # more numerically stable than t = 1-c + + R = np.array([ + [t*vx*vx + c, t*vx*vy - vz*s, t*vx*vz + vy*s], + [t*vy*vx + vz*s, t*vy*vy + c, t*vy*vz - vx*s], + [t*vz*vx - vy*s, t*vz*vy + vx*s, t*vz*vz + c]]) + + return R + + +def _view_axes(E, R, V, roll): + """ + Get the unit viewing axes in data coordinates. + + Parameters + ---------- + E : 3-element numpy array + The coordinates of the eye/camera. + R : 3-element numpy array + The coordinates of the center of the view box. + V : 3-element numpy array + Unit vector in the direction of the vertical axis. + roll : float + The roll angle in radians. + + Returns + ------- + u : 3-element numpy array + Unit vector pointing towards the right of the screen. + v : 3-element numpy array + Unit vector pointing towards the top of the screen. + w : 3-element numpy array + Unit vector pointing out of the screen. + """ + w = (E - R) + w = w/np.linalg.norm(w) + u = np.cross(V, w) + u = u/np.linalg.norm(u) + v = np.cross(w, u) # Will be a unit vector + + # Save some computation for the default roll=0 + if roll != 0: + # A positive rotation of the camera is a negative rotation of the world + Rroll = _rotation_about_vector(w, -roll) + u = np.dot(Rroll, u) + v = np.dot(Rroll, v) + return u, v, w + + +def _view_transformation_uvw(u, v, w, E): + """ + Return the view transformation matrix. + + Parameters + ---------- + u : 3-element numpy array + Unit vector pointing towards the right of the screen. + v : 3-element numpy array + Unit vector pointing towards the top of the screen. + w : 3-element numpy array + Unit vector pointing out of the screen. + E : 3-element numpy array + The coordinates of the eye/camera. + """ + Mr = np.eye(4) + Mt = np.eye(4) + Mr[:3, :3] = [u, v, w] + Mt[:3, -1] = -E + M = np.dot(Mr, Mt) + return M + + +def _persp_transformation(zfront, zback, focal_length): + e = focal_length + a = 1 # aspect ratio + b = (zfront+zback)/(zfront-zback) + c = -2*(zfront*zback)/(zfront-zback) + proj_matrix = np.array([[e, 0, 0, 0], + [0, e/a, 0, 0], + [0, 0, b, c], + [0, 0, -1, 0]]) + return proj_matrix + + +def _ortho_transformation(zfront, zback): + # note: w component in the resulting vector will be (zback-zfront), not 1 + a = -(zfront + zback) + b = -(zfront - zback) + proj_matrix = np.array([[2, 0, 0, 0], + [0, 2, 0, 0], + [0, 0, -2, 0], + [0, 0, a, b]]) + return proj_matrix + + +def _proj_transform_vec(vec, M): + vecw = np.dot(M, vec.data) + w = vecw[3] + txs, tys, tzs = vecw[0]/w, vecw[1]/w, vecw[2]/w + if np.ma.isMA(vec[0]): # we check each to protect for scalars + txs = np.ma.array(txs, mask=vec[0].mask) + if np.ma.isMA(vec[1]): + tys = np.ma.array(tys, mask=vec[1].mask) + if np.ma.isMA(vec[2]): + tzs = np.ma.array(tzs, mask=vec[2].mask) + return txs, tys, tzs + + +def _proj_transform_vec_clip(vec, M, focal_length): + vecw = np.dot(M, vec.data) + w = vecw[3] + txs, tys, tzs = vecw[0] / w, vecw[1] / w, vecw[2] / w + if np.isinf(focal_length): # don't clip orthographic projection + tis = np.ones(txs.shape, dtype=bool) + else: + tis = (-1 <= txs) & (txs <= 1) & (-1 <= tys) & (tys <= 1) & (tzs <= 0) + if np.ma.isMA(vec[0]): + tis = tis & ~vec[0].mask + if np.ma.isMA(vec[1]): + tis = tis & ~vec[1].mask + if np.ma.isMA(vec[2]): + tis = tis & ~vec[2].mask + + txs = np.ma.masked_array(txs, ~tis) + tys = np.ma.masked_array(tys, ~tis) + tzs = np.ma.masked_array(tzs, ~tis) + return txs, tys, tzs, tis + + +def inv_transform(xs, ys, zs, invM): + """ + Transform the points by the inverse of the projection matrix, *invM*. + """ + vec = _vec_pad_ones(xs, ys, zs) + vecr = np.dot(invM, vec) + if vecr.shape == (4,): + vecr = vecr.reshape((4, 1)) + for i in range(vecr.shape[1]): + if vecr[3][i] != 0: + vecr[:, i] = vecr[:, i] / vecr[3][i] + return vecr[0], vecr[1], vecr[2] + + +def _vec_pad_ones(xs, ys, zs): + if np.ma.isMA(xs) or np.ma.isMA(ys) or np.ma.isMA(zs): + return np.ma.array([xs, ys, zs, np.ones_like(xs)]) + else: + return np.array([xs, ys, zs, np.ones_like(xs)]) + + +def proj_transform(xs, ys, zs, M): + """ + Transform the points by the projection matrix *M*. + """ + vec = _vec_pad_ones(xs, ys, zs) + return _proj_transform_vec(vec, M) + + +@_api.deprecated("3.10") +def proj_transform_clip(xs, ys, zs, M): + return _proj_transform_clip(xs, ys, zs, M, focal_length=np.inf) + + +def _proj_transform_clip(xs, ys, zs, M, focal_length): + """ + Transform the points by the projection matrix + and return the clipping result + returns txs, tys, tzs, tis + """ + vec = _vec_pad_ones(xs, ys, zs) + return _proj_transform_vec_clip(vec, M, focal_length) + + +def _proj_points(points, M): + return np.column_stack(_proj_trans_points(points, M)) + + +def _proj_trans_points(points, M): + points = np.asanyarray(points) + xs, ys, zs = points[:, 0], points[:, 1], points[:, 2] + return proj_transform(xs, ys, zs, M) diff --git a/lib/python3.12/site-packages/mpl_toolkits/mplot3d/tests/__init__.py b/lib/python3.12/site-packages/mpl_toolkits/mplot3d/tests/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..ea4d8ed16a6a24a8c15ab2956ef678a7f256cd80 --- /dev/null +++ b/lib/python3.12/site-packages/mpl_toolkits/mplot3d/tests/__init__.py @@ -0,0 +1,10 @@ +from pathlib import Path + + +# Check that the test directories exist +if not (Path(__file__).parent / "baseline_images").exists(): + raise OSError( + 'The baseline image directory does not exist. ' + 'This is most likely because the test data is not installed. ' + 'You may need to install matplotlib from source to get the ' + 'test data.') diff --git a/lib/python3.12/site-packages/mpl_toolkits/mplot3d/tests/__pycache__/__init__.cpython-312.pyc b/lib/python3.12/site-packages/mpl_toolkits/mplot3d/tests/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..176aa0e05415da934b69c07e04086c65d53616e4 Binary files /dev/null and b/lib/python3.12/site-packages/mpl_toolkits/mplot3d/tests/__pycache__/__init__.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/mpl_toolkits/mplot3d/tests/__pycache__/conftest.cpython-312.pyc b/lib/python3.12/site-packages/mpl_toolkits/mplot3d/tests/__pycache__/conftest.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5ac452a8f450b44ac284f70e4ed5aa17e87cb22f Binary files /dev/null and b/lib/python3.12/site-packages/mpl_toolkits/mplot3d/tests/__pycache__/conftest.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/mpl_toolkits/mplot3d/tests/__pycache__/test_art3d.cpython-312.pyc b/lib/python3.12/site-packages/mpl_toolkits/mplot3d/tests/__pycache__/test_art3d.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..338cd46d766b9c47dc088687e423a738a8224f7d Binary files /dev/null and b/lib/python3.12/site-packages/mpl_toolkits/mplot3d/tests/__pycache__/test_art3d.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/mpl_toolkits/mplot3d/tests/__pycache__/test_legend3d.cpython-312.pyc b/lib/python3.12/site-packages/mpl_toolkits/mplot3d/tests/__pycache__/test_legend3d.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3e3d263537661cdba7f61a5a12b871e39635b9b4 Binary files /dev/null and b/lib/python3.12/site-packages/mpl_toolkits/mplot3d/tests/__pycache__/test_legend3d.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/mpl_toolkits/mplot3d/tests/conftest.py b/lib/python3.12/site-packages/mpl_toolkits/mplot3d/tests/conftest.py new file mode 100644 index 0000000000000000000000000000000000000000..61c2de3e07bac4db323f8704961264d123e01544 --- /dev/null +++ b/lib/python3.12/site-packages/mpl_toolkits/mplot3d/tests/conftest.py @@ -0,0 +1,2 @@ +from matplotlib.testing.conftest import (mpl_test_settings, # noqa + pytest_configure, pytest_unconfigure) diff --git a/lib/python3.12/site-packages/mpl_toolkits/mplot3d/tests/test_art3d.py b/lib/python3.12/site-packages/mpl_toolkits/mplot3d/tests/test_art3d.py new file mode 100644 index 0000000000000000000000000000000000000000..174c12608ae933c1b918f45e57abfe6a2fb552bd --- /dev/null +++ b/lib/python3.12/site-packages/mpl_toolkits/mplot3d/tests/test_art3d.py @@ -0,0 +1,102 @@ +import numpy as np + +import matplotlib.pyplot as plt + +from matplotlib.backend_bases import MouseEvent +from mpl_toolkits.mplot3d.art3d import ( + Line3DCollection, + Poly3DCollection, + _all_points_on_plane, +) + + +def test_scatter_3d_projection_conservation(): + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + # fix axes3d projection + ax.roll = 0 + ax.elev = 0 + ax.azim = -45 + ax.stale = True + + x = [0, 1, 2, 3, 4] + scatter_collection = ax.scatter(x, x, x) + fig.canvas.draw_idle() + + # Get scatter location on canvas and freeze the data + scatter_offset = scatter_collection.get_offsets() + scatter_location = ax.transData.transform(scatter_offset) + + # Yaw -44 and -46 are enough to produce two set of scatter + # with opposite z-order without moving points too far + for azim in (-44, -46): + ax.azim = azim + ax.stale = True + fig.canvas.draw_idle() + + for i in range(5): + # Create a mouse event used to locate and to get index + # from each dots + event = MouseEvent("button_press_event", fig.canvas, + *scatter_location[i, :]) + contains, ind = scatter_collection.contains(event) + assert contains is True + assert len(ind["ind"]) == 1 + assert ind["ind"][0] == i + + +def test_zordered_error(): + # Smoke test for https://github.com/matplotlib/matplotlib/issues/26497 + lc = [(np.fromiter([0.0, 0.0, 0.0], dtype="float"), + np.fromiter([1.0, 1.0, 1.0], dtype="float"))] + pc = [np.fromiter([0.0, 0.0], dtype="float"), + np.fromiter([0.0, 1.0], dtype="float"), + np.fromiter([1.0, 1.0], dtype="float")] + + fig = plt.figure() + ax = fig.add_subplot(projection="3d") + ax.add_collection(Line3DCollection(lc)) + ax.scatter(*pc, visible=False) + plt.draw() + + +def test_all_points_on_plane(): + # Non-coplanar points + points = np.array([[0, 0, 0], [1, 0, 0], [0, 1, 0], [0, 0, 1]]) + assert not _all_points_on_plane(*points.T) + + # Duplicate points + points = np.array([[0, 0, 0], [1, 0, 0], [0, 1, 0], [0, 0, 0]]) + assert _all_points_on_plane(*points.T) + + # NaN values + points = np.array([[0, 0, 0], [1, 0, 0], [0, 1, 0], [0, 0, np.nan]]) + assert _all_points_on_plane(*points.T) + + # Less than 3 unique points + points = np.array([[0, 0, 0], [0, 0, 0], [0, 0, 0]]) + assert _all_points_on_plane(*points.T) + + # All points lie on a line + points = np.array([[0, 0, 0], [0, 1, 0], [0, 2, 0], [0, 3, 0]]) + assert _all_points_on_plane(*points.T) + + # All points lie on two lines, with antiparallel vectors + points = np.array([[-2, 2, 0], [-1, 1, 0], [1, -1, 0], + [0, 0, 0], [2, 0, 0], [1, 0, 0]]) + assert _all_points_on_plane(*points.T) + + # All points lie on a plane + points = np.array([[0, 0, 0], [0, 1, 0], [1, 0, 0], [1, 1, 0], [1, 2, 0]]) + assert _all_points_on_plane(*points.T) + + +def test_generate_normals(): + # Smoke test for https://github.com/matplotlib/matplotlib/issues/29156 + vertices = ((0, 0, 0), (0, 5, 0), (5, 5, 0), (5, 0, 0)) + shape = Poly3DCollection([vertices], edgecolors='r', shade=True) + + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + ax.add_collection3d(shape) + plt.draw() diff --git a/lib/python3.12/site-packages/mpl_toolkits/mplot3d/tests/test_axes3d.py b/lib/python3.12/site-packages/mpl_toolkits/mplot3d/tests/test_axes3d.py new file mode 100644 index 0000000000000000000000000000000000000000..b30c2556b105e70e3e08fa3877c712f78f09d95c --- /dev/null +++ b/lib/python3.12/site-packages/mpl_toolkits/mplot3d/tests/test_axes3d.py @@ -0,0 +1,2688 @@ +import functools +import itertools +import platform +import sys + +import pytest + +from mpl_toolkits.mplot3d import Axes3D, axes3d, proj3d, art3d +from mpl_toolkits.mplot3d.axes3d import _Quaternion as Quaternion +import matplotlib as mpl +from matplotlib.backend_bases import (MouseButton, MouseEvent, + NavigationToolbar2) +from matplotlib import cm +from matplotlib import colors as mcolors, patches as mpatch +from matplotlib.testing.decorators import image_comparison, check_figures_equal +from matplotlib.testing.widgets import mock_event +from matplotlib.collections import LineCollection, PolyCollection +from matplotlib.patches import Circle, PathPatch +from matplotlib.path import Path +from matplotlib.text import Text + +import matplotlib.pyplot as plt +import numpy as np + + +mpl3d_image_comparison = functools.partial( + image_comparison, remove_text=True, style='default') + + +def plot_cuboid(ax, scale): + # plot a rectangular cuboid with side lengths given by scale (x, y, z) + r = [0, 1] + pts = itertools.combinations(np.array(list(itertools.product(r, r, r))), 2) + for start, end in pts: + if np.sum(np.abs(start - end)) == r[1] - r[0]: + ax.plot3D(*zip(start*np.array(scale), end*np.array(scale))) + + +@check_figures_equal(extensions=["png"]) +def test_invisible_axes(fig_test, fig_ref): + ax = fig_test.subplots(subplot_kw=dict(projection='3d')) + ax.set_visible(False) + + +@mpl3d_image_comparison(['grid_off.png'], style='mpl20') +def test_grid_off(): + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + ax.grid(False) + + +@mpl3d_image_comparison(['invisible_ticks_axis.png'], style='mpl20') +def test_invisible_ticks_axis(): + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + ax.set_xticks([]) + ax.set_yticks([]) + ax.set_zticks([]) + for axis in [ax.xaxis, ax.yaxis, ax.zaxis]: + axis.line.set_visible(False) + + +@mpl3d_image_comparison(['axis_positions.png'], remove_text=False, style='mpl20') +def test_axis_positions(): + positions = ['upper', 'lower', 'both', 'none'] + fig, axs = plt.subplots(2, 2, subplot_kw={'projection': '3d'}) + for ax, pos in zip(axs.flatten(), positions): + for axis in ax.xaxis, ax.yaxis, ax.zaxis: + axis.set_label_position(pos) + axis.set_ticks_position(pos) + title = f'{pos}' + ax.set(xlabel='x', ylabel='y', zlabel='z', title=title) + + +@mpl3d_image_comparison(['aspects.png'], remove_text=False, style='mpl20') +def test_aspects(): + aspects = ('auto', 'equal', 'equalxy', 'equalyz', 'equalxz', 'equal') + _, axs = plt.subplots(2, 3, subplot_kw={'projection': '3d'}) + + for ax in axs.flatten()[0:-1]: + plot_cuboid(ax, scale=[1, 1, 5]) + # plot a cube as well to cover github #25443 + plot_cuboid(axs[1][2], scale=[1, 1, 1]) + + for i, ax in enumerate(axs.flatten()): + ax.set_title(aspects[i]) + ax.set_box_aspect((3, 4, 5)) + ax.set_aspect(aspects[i], adjustable='datalim') + axs[1][2].set_title('equal (cube)') + + +@mpl3d_image_comparison(['aspects_adjust_box.png'], + remove_text=False, style='mpl20') +def test_aspects_adjust_box(): + aspects = ('auto', 'equal', 'equalxy', 'equalyz', 'equalxz') + fig, axs = plt.subplots(1, len(aspects), subplot_kw={'projection': '3d'}, + figsize=(11, 3)) + + for i, ax in enumerate(axs): + plot_cuboid(ax, scale=[4, 3, 5]) + ax.set_title(aspects[i]) + ax.set_aspect(aspects[i], adjustable='box') + + +def test_axes3d_repr(): + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + ax.set_label('label') + ax.set_title('title') + ax.set_xlabel('x') + ax.set_ylabel('y') + ax.set_zlabel('z') + assert repr(ax) == ( + "<Axes3D: label='label', " + "title={'center': 'title'}, xlabel='x', ylabel='y', zlabel='z'>") + + +@mpl3d_image_comparison(['axes3d_primary_views.png'], style='mpl20', + tol=0.05 if sys.platform == "darwin" else 0) +def test_axes3d_primary_views(): + # (elev, azim, roll) + views = [(90, -90, 0), # XY + (0, -90, 0), # XZ + (0, 0, 0), # YZ + (-90, 90, 0), # -XY + (0, 90, 0), # -XZ + (0, 180, 0)] # -YZ + # When viewing primary planes, draw the two visible axes so they intersect + # at their low values + fig, axs = plt.subplots(2, 3, subplot_kw={'projection': '3d'}) + for i, ax in enumerate(axs.flat): + ax.set_xlabel('x') + ax.set_ylabel('y') + ax.set_zlabel('z') + ax.set_proj_type('ortho') + ax.view_init(elev=views[i][0], azim=views[i][1], roll=views[i][2]) + plt.tight_layout() + + +@mpl3d_image_comparison(['bar3d.png'], style='mpl20') +def test_bar3d(): + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + for c, z in zip(['r', 'g', 'b', 'y'], [30, 20, 10, 0]): + xs = np.arange(20) + ys = np.arange(20) + cs = [c] * len(xs) + cs[0] = 'c' + ax.bar(xs, ys, zs=z, zdir='y', align='edge', color=cs, alpha=0.8) + + +def test_bar3d_colors(): + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + for c in ['red', 'green', 'blue', 'yellow']: + xs = np.arange(len(c)) + ys = np.zeros_like(xs) + zs = np.zeros_like(ys) + # Color names with same length as xs/ys/zs should not be split into + # individual letters. + ax.bar3d(xs, ys, zs, 1, 1, 1, color=c) + + +@mpl3d_image_comparison(['bar3d_shaded.png'], style='mpl20') +def test_bar3d_shaded(): + x = np.arange(4) + y = np.arange(5) + x2d, y2d = np.meshgrid(x, y) + x2d, y2d = x2d.ravel(), y2d.ravel() + z = x2d + y2d + 1 # Avoid triggering bug with zero-depth boxes. + + views = [(30, -60, 0), (30, 30, 30), (-30, 30, -90), (300, -30, 0)] + fig = plt.figure(figsize=plt.figaspect(1 / len(views))) + axs = fig.subplots( + 1, len(views), + subplot_kw=dict(projection='3d') + ) + for ax, (elev, azim, roll) in zip(axs, views): + ax.bar3d(x2d, y2d, x2d * 0, 1, 1, z, shade=True) + ax.view_init(elev=elev, azim=azim, roll=roll) + fig.canvas.draw() + + +@mpl3d_image_comparison(['bar3d_notshaded.png'], style='mpl20') +def test_bar3d_notshaded(): + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + x = np.arange(4) + y = np.arange(5) + x2d, y2d = np.meshgrid(x, y) + x2d, y2d = x2d.ravel(), y2d.ravel() + z = x2d + y2d + ax.bar3d(x2d, y2d, x2d * 0, 1, 1, z, shade=False) + fig.canvas.draw() + + +def test_bar3d_lightsource(): + fig = plt.figure() + ax = fig.add_subplot(1, 1, 1, projection="3d") + + ls = mcolors.LightSource(azdeg=0, altdeg=90) + + length, width = 3, 4 + area = length * width + + x, y = np.meshgrid(np.arange(length), np.arange(width)) + x = x.ravel() + y = y.ravel() + dz = x + y + + color = [cm.coolwarm(i/area) for i in range(area)] + + collection = ax.bar3d(x=x, y=y, z=0, + dx=1, dy=1, dz=dz, + color=color, shade=True, lightsource=ls) + + # Testing that the custom 90° lightsource produces different shading on + # the top facecolors compared to the default, and that those colors are + # precisely (within floating point rounding errors of 4 ULP) the colors + # from the colormap, due to the illumination parallel to the z-axis. + np.testing.assert_array_max_ulp(color, collection._facecolor3d[1::6], 4) + + +@mpl3d_image_comparison(['contour3d.png'], style='mpl20', + tol=0 if platform.machine() == 'x86_64' else 0.002) +def test_contour3d(): + plt.rcParams['axes3d.automargin'] = True # Remove when image is regenerated + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + X, Y, Z = axes3d.get_test_data(0.05) + ax.contour(X, Y, Z, zdir='z', offset=-100, cmap=cm.coolwarm) + ax.contour(X, Y, Z, zdir='x', offset=-40, cmap=cm.coolwarm) + ax.contour(X, Y, Z, zdir='y', offset=40, cmap=cm.coolwarm) + ax.axis(xmin=-40, xmax=40, ymin=-40, ymax=40, zmin=-100, zmax=100) + + +@mpl3d_image_comparison(['contour3d_extend3d.png'], style='mpl20') +def test_contour3d_extend3d(): + plt.rcParams['axes3d.automargin'] = True # Remove when image is regenerated + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + X, Y, Z = axes3d.get_test_data(0.05) + ax.contour(X, Y, Z, zdir='z', offset=-100, cmap=cm.coolwarm, extend3d=True) + ax.set_xlim(-30, 30) + ax.set_ylim(-20, 40) + ax.set_zlim(-80, 80) + + +@mpl3d_image_comparison(['contourf3d.png'], style='mpl20') +def test_contourf3d(): + plt.rcParams['axes3d.automargin'] = True # Remove when image is regenerated + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + X, Y, Z = axes3d.get_test_data(0.05) + ax.contourf(X, Y, Z, zdir='z', offset=-100, cmap=cm.coolwarm) + ax.contourf(X, Y, Z, zdir='x', offset=-40, cmap=cm.coolwarm) + ax.contourf(X, Y, Z, zdir='y', offset=40, cmap=cm.coolwarm) + ax.set_xlim(-40, 40) + ax.set_ylim(-40, 40) + ax.set_zlim(-100, 100) + + +@mpl3d_image_comparison(['contourf3d_fill.png'], style='mpl20') +def test_contourf3d_fill(): + plt.rcParams['axes3d.automargin'] = True # Remove when image is regenerated + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + X, Y = np.meshgrid(np.arange(-2, 2, 0.25), np.arange(-2, 2, 0.25)) + Z = X.clip(0, 0) + # This produces holes in the z=0 surface that causes rendering errors if + # the Poly3DCollection is not aware of path code information (issue #4784) + Z[::5, ::5] = 0.1 + ax.contourf(X, Y, Z, offset=0, levels=[-0.1, 0], cmap=cm.coolwarm) + ax.set_xlim(-2, 2) + ax.set_ylim(-2, 2) + ax.set_zlim(-1, 1) + + +@pytest.mark.parametrize('extend, levels', [['both', [2, 4, 6]], + ['min', [2, 4, 6, 8]], + ['max', [0, 2, 4, 6]]]) +@check_figures_equal(extensions=["png"]) +def test_contourf3d_extend(fig_test, fig_ref, extend, levels): + X, Y = np.meshgrid(np.arange(-2, 2, 0.25), np.arange(-2, 2, 0.25)) + # Z is in the range [0, 8] + Z = X**2 + Y**2 + + # Manually set the over/under colors to be the end of the colormap + cmap = mpl.colormaps['viridis'].copy() + cmap.set_under(cmap(0)) + cmap.set_over(cmap(255)) + # Set vmin/max to be the min/max values plotted on the reference image + kwargs = {'vmin': 1, 'vmax': 7, 'cmap': cmap} + + ax_ref = fig_ref.add_subplot(projection='3d') + ax_ref.contourf(X, Y, Z, levels=[0, 2, 4, 6, 8], **kwargs) + + ax_test = fig_test.add_subplot(projection='3d') + ax_test.contourf(X, Y, Z, levels, extend=extend, **kwargs) + + for ax in [ax_ref, ax_test]: + ax.set_xlim(-2, 2) + ax.set_ylim(-2, 2) + ax.set_zlim(-10, 10) + + +@mpl3d_image_comparison(['tricontour.png'], tol=0.02, style='mpl20') +def test_tricontour(): + plt.rcParams['axes3d.automargin'] = True # Remove when image is regenerated + fig = plt.figure() + + np.random.seed(19680801) + x = np.random.rand(1000) - 0.5 + y = np.random.rand(1000) - 0.5 + z = -(x**2 + y**2) + + ax = fig.add_subplot(1, 2, 1, projection='3d') + ax.tricontour(x, y, z) + ax = fig.add_subplot(1, 2, 2, projection='3d') + ax.tricontourf(x, y, z) + + +def test_contour3d_1d_input(): + # Check that 1D sequences of different length for {x, y} doesn't error + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + nx, ny = 30, 20 + x = np.linspace(-10, 10, nx) + y = np.linspace(-10, 10, ny) + z = np.random.randint(0, 2, [ny, nx]) + ax.contour(x, y, z, [0.5]) + + +@mpl3d_image_comparison(['lines3d.png'], style='mpl20') +def test_lines3d(): + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + theta = np.linspace(-4 * np.pi, 4 * np.pi, 100) + z = np.linspace(-2, 2, 100) + r = z ** 2 + 1 + x = r * np.sin(theta) + y = r * np.cos(theta) + ax.plot(x, y, z) + + +@check_figures_equal(extensions=["png"]) +def test_plot_scalar(fig_test, fig_ref): + ax1 = fig_test.add_subplot(projection='3d') + ax1.plot([1], [1], "o") + ax2 = fig_ref.add_subplot(projection='3d') + ax2.plot(1, 1, "o") + + +def test_invalid_line_data(): + with pytest.raises(RuntimeError, match='x must be'): + art3d.Line3D(0, [], []) + with pytest.raises(RuntimeError, match='y must be'): + art3d.Line3D([], 0, []) + with pytest.raises(RuntimeError, match='z must be'): + art3d.Line3D([], [], 0) + + line = art3d.Line3D([], [], []) + with pytest.raises(RuntimeError, match='x must be'): + line.set_data_3d(0, [], []) + with pytest.raises(RuntimeError, match='y must be'): + line.set_data_3d([], 0, []) + with pytest.raises(RuntimeError, match='z must be'): + line.set_data_3d([], [], 0) + + +@mpl3d_image_comparison(['mixedsubplot.png'], style='mpl20') +def test_mixedsubplots(): + def f(t): + return np.cos(2*np.pi*t) * np.exp(-t) + + t1 = np.arange(0.0, 5.0, 0.1) + t2 = np.arange(0.0, 5.0, 0.02) + + plt.rcParams['axes3d.automargin'] = True # Remove when image is regenerated + fig = plt.figure(figsize=plt.figaspect(2.)) + ax = fig.add_subplot(2, 1, 1) + ax.plot(t1, f(t1), 'bo', t2, f(t2), 'k--', markerfacecolor='green') + ax.grid(True) + + ax = fig.add_subplot(2, 1, 2, projection='3d') + X, Y = np.meshgrid(np.arange(-5, 5, 0.25), np.arange(-5, 5, 0.25)) + R = np.hypot(X, Y) + Z = np.sin(R) + + ax.plot_surface(X, Y, Z, rcount=40, ccount=40, + linewidth=0, antialiased=False) + + ax.set_zlim3d(-1, 1) + + +@check_figures_equal(extensions=['png']) +def test_tight_layout_text(fig_test, fig_ref): + # text is currently ignored in tight layout. So the order of text() and + # tight_layout() calls should not influence the result. + ax1 = fig_test.add_subplot(projection='3d') + ax1.text(.5, .5, .5, s='some string') + fig_test.tight_layout() + + ax2 = fig_ref.add_subplot(projection='3d') + fig_ref.tight_layout() + ax2.text(.5, .5, .5, s='some string') + + +@mpl3d_image_comparison(['scatter3d.png'], style='mpl20') +def test_scatter3d(): + plt.rcParams['axes3d.automargin'] = True # Remove when image is regenerated + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + ax.scatter(np.arange(10), np.arange(10), np.arange(10), + c='r', marker='o') + x = y = z = np.arange(10, 20) + ax.scatter(x, y, z, c='b', marker='^') + z[-1] = 0 # Check that scatter() copies the data. + # Ensure empty scatters do not break. + ax.scatter([], [], [], c='r', marker='X') + + +@mpl3d_image_comparison(['scatter3d_color.png'], style='mpl20') +def test_scatter3d_color(): + plt.rcParams['axes3d.automargin'] = True # Remove when image is regenerated + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + + # Check that 'none' color works; these two should overlay to produce the + # same as setting just `color`. + ax.scatter(np.arange(10), np.arange(10), np.arange(10), + facecolor='r', edgecolor='none', marker='o') + ax.scatter(np.arange(10), np.arange(10), np.arange(10), + facecolor='none', edgecolor='r', marker='o') + + ax.scatter(np.arange(10, 20), np.arange(10, 20), np.arange(10, 20), + color='b', marker='s') + + +@mpl3d_image_comparison(['scatter3d_linewidth.png'], style='mpl20') +def test_scatter3d_linewidth(): + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + + # Check that array-like linewidth can be set + ax.scatter(np.arange(10), np.arange(10), np.arange(10), + marker='o', linewidth=np.arange(10)) + + +@check_figures_equal(extensions=['png']) +def test_scatter3d_linewidth_modification(fig_ref, fig_test): + # Changing Path3DCollection linewidths with array-like post-creation + # should work correctly. + ax_test = fig_test.add_subplot(projection='3d') + c = ax_test.scatter(np.arange(10), np.arange(10), np.arange(10), + marker='o') + c.set_linewidths(np.arange(10)) + + ax_ref = fig_ref.add_subplot(projection='3d') + ax_ref.scatter(np.arange(10), np.arange(10), np.arange(10), marker='o', + linewidths=np.arange(10)) + + +@check_figures_equal(extensions=['png']) +def test_scatter3d_modification(fig_ref, fig_test): + # Changing Path3DCollection properties post-creation should work correctly. + ax_test = fig_test.add_subplot(projection='3d') + c = ax_test.scatter(np.arange(10), np.arange(10), np.arange(10), + marker='o') + c.set_facecolor('C1') + c.set_edgecolor('C2') + c.set_alpha([0.3, 0.7] * 5) + assert c.get_depthshade() + c.set_depthshade(False) + assert not c.get_depthshade() + c.set_sizes(np.full(10, 75)) + c.set_linewidths(3) + + ax_ref = fig_ref.add_subplot(projection='3d') + ax_ref.scatter(np.arange(10), np.arange(10), np.arange(10), marker='o', + facecolor='C1', edgecolor='C2', alpha=[0.3, 0.7] * 5, + depthshade=False, s=75, linewidths=3) + + +@pytest.mark.parametrize('depthshade', [True, False]) +@check_figures_equal(extensions=['png']) +def test_scatter3d_sorting(fig_ref, fig_test, depthshade): + """Test that marker properties are correctly sorted.""" + + y, x = np.mgrid[:10, :10] + z = np.arange(x.size).reshape(x.shape) + + sizes = np.full(z.shape, 25) + sizes[0::2, 0::2] = 100 + sizes[1::2, 1::2] = 100 + + facecolors = np.full(z.shape, 'C0') + facecolors[:5, :5] = 'C1' + facecolors[6:, :4] = 'C2' + facecolors[6:, 6:] = 'C3' + + edgecolors = np.full(z.shape, 'C4') + edgecolors[1:5, 1:5] = 'C5' + edgecolors[5:9, 1:5] = 'C6' + edgecolors[5:9, 5:9] = 'C7' + + linewidths = np.full(z.shape, 2) + linewidths[0::2, 0::2] = 5 + linewidths[1::2, 1::2] = 5 + + x, y, z, sizes, facecolors, edgecolors, linewidths = ( + a.flatten() + for a in [x, y, z, sizes, facecolors, edgecolors, linewidths] + ) + + ax_ref = fig_ref.add_subplot(projection='3d') + sets = (np.unique(a) for a in [sizes, facecolors, edgecolors, linewidths]) + for s, fc, ec, lw in itertools.product(*sets): + subset = ( + (sizes != s) | + (facecolors != fc) | + (edgecolors != ec) | + (linewidths != lw) + ) + subset = np.ma.masked_array(z, subset, dtype=float) + + # When depth shading is disabled, the colors are passed through as + # single-item lists; this triggers single path optimization. The + # following reshaping is a hack to disable that, since the optimization + # would not occur for the full scatter which has multiple colors. + fc = np.repeat(fc, sum(~subset.mask)) + + ax_ref.scatter(x, y, subset, s=s, fc=fc, ec=ec, lw=lw, alpha=1, + depthshade=depthshade) + + ax_test = fig_test.add_subplot(projection='3d') + ax_test.scatter(x, y, z, s=sizes, fc=facecolors, ec=edgecolors, + lw=linewidths, alpha=1, depthshade=depthshade) + + +@pytest.mark.parametrize('azim', [-50, 130]) # yellow first, blue first +@check_figures_equal(extensions=['png']) +def test_marker_draw_order_data_reversed(fig_test, fig_ref, azim): + """ + Test that the draw order does not depend on the data point order. + + For the given viewing angle at azim=-50, the yellow marker should be in + front. For azim=130, the blue marker should be in front. + """ + x = [-1, 1] + y = [1, -1] + z = [0, 0] + color = ['b', 'y'] + ax = fig_test.add_subplot(projection='3d') + ax.scatter(x, y, z, s=3500, c=color) + ax.view_init(elev=0, azim=azim, roll=0) + ax = fig_ref.add_subplot(projection='3d') + ax.scatter(x[::-1], y[::-1], z[::-1], s=3500, c=color[::-1]) + ax.view_init(elev=0, azim=azim, roll=0) + + +@check_figures_equal(extensions=['png']) +def test_marker_draw_order_view_rotated(fig_test, fig_ref): + """ + Test that the draw order changes with the direction. + + If we rotate *azim* by 180 degrees and exchange the colors, the plot + plot should look the same again. + """ + azim = 130 + x = [-1, 1] + y = [1, -1] + z = [0, 0] + color = ['b', 'y'] + ax = fig_test.add_subplot(projection='3d') + # axis are not exactly invariant under 180 degree rotation -> deactivate + ax.set_axis_off() + ax.scatter(x, y, z, s=3500, c=color) + ax.view_init(elev=0, azim=azim, roll=0) + ax = fig_ref.add_subplot(projection='3d') + ax.set_axis_off() + ax.scatter(x, y, z, s=3500, c=color[::-1]) # color reversed + ax.view_init(elev=0, azim=azim - 180, roll=0) # view rotated by 180 deg + + +@mpl3d_image_comparison(['plot_3d_from_2d.png'], tol=0.019, style='mpl20') +def test_plot_3d_from_2d(): + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + xs = np.arange(0, 5) + ys = np.arange(5, 10) + ax.plot(xs, ys, zs=0, zdir='x') + ax.plot(xs, ys, zs=0, zdir='y') + + +@mpl3d_image_comparison(['fill_between_quad.png'], style='mpl20') +def test_fill_between_quad(): + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + + theta = np.linspace(0, 2*np.pi, 50) + + x1 = np.cos(theta) + y1 = np.sin(theta) + z1 = 0.1 * np.sin(6 * theta) + + x2 = 0.6 * np.cos(theta) + y2 = 0.6 * np.sin(theta) + z2 = 2 + + where = (theta < np.pi/2) | (theta > 3*np.pi/2) + + # Since none of x1 == x2, y1 == y2, or z1 == z2 is True, the fill_between + # mode will map to 'quad' + ax.fill_between(x1, y1, z1, x2, y2, z2, + where=where, mode='auto', alpha=0.5, edgecolor='k') + + +@mpl3d_image_comparison(['fill_between_polygon.png'], style='mpl20') +def test_fill_between_polygon(): + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + + theta = np.linspace(0, 2*np.pi, 50) + + x1 = x2 = theta + y1 = y2 = 0 + z1 = np.cos(theta) + z2 = z1 + 1 + + where = (theta < np.pi/2) | (theta > 3*np.pi/2) + + # Since x1 == x2 and y1 == y2, the fill_between mode will be 'polygon' + ax.fill_between(x1, y1, z1, x2, y2, z2, + where=where, mode='auto', edgecolor='k') + + +@mpl3d_image_comparison(['surface3d.png'], style='mpl20') +def test_surface3d(): + # Remove this line when this test image is regenerated. + plt.rcParams['pcolormesh.snap'] = False + + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + X = np.arange(-5, 5, 0.25) + Y = np.arange(-5, 5, 0.25) + X, Y = np.meshgrid(X, Y) + R = np.hypot(X, Y) + Z = np.sin(R) + surf = ax.plot_surface(X, Y, Z, rcount=40, ccount=40, cmap=cm.coolwarm, + lw=0, antialiased=False) + plt.rcParams['axes3d.automargin'] = True # Remove when image is regenerated + ax.set_zlim(-1.01, 1.01) + fig.colorbar(surf, shrink=0.5, aspect=5) + + +@image_comparison(['surface3d_label_offset_tick_position.png'], style='mpl20') +def test_surface3d_label_offset_tick_position(): + plt.rcParams['axes3d.automargin'] = True # Remove when image is regenerated + ax = plt.figure().add_subplot(projection="3d") + + x, y = np.mgrid[0:6 * np.pi:0.25, 0:4 * np.pi:0.25] + z = np.sqrt(np.abs(np.cos(x) + np.cos(y))) + + ax.plot_surface(x * 1e5, y * 1e6, z * 1e8, cmap='autumn', cstride=2, rstride=2) + ax.set_xlabel("X label") + ax.set_ylabel("Y label") + ax.set_zlabel("Z label") + + +@mpl3d_image_comparison(['surface3d_shaded.png'], style='mpl20') +def test_surface3d_shaded(): + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + X = np.arange(-5, 5, 0.25) + Y = np.arange(-5, 5, 0.25) + X, Y = np.meshgrid(X, Y) + R = np.sqrt(X ** 2 + Y ** 2) + Z = np.sin(R) + ax.plot_surface(X, Y, Z, rstride=5, cstride=5, + color=[0.25, 1, 0.25], lw=1, antialiased=False) + plt.rcParams['axes3d.automargin'] = True # Remove when image is regenerated + ax.set_zlim(-1.01, 1.01) + + +@mpl3d_image_comparison(['surface3d_masked.png'], style='mpl20') +def test_surface3d_masked(): + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + x = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] + y = [1, 2, 3, 4, 5, 6, 7, 8] + + x, y = np.meshgrid(x, y) + matrix = np.array( + [ + [-1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [-1, 1, 2, 3, 4, 4, 4, 3, 2, 1, 1], + [-1, -1., 4, 5, 6, 8, 6, 5, 4, 3, -1.], + [-1, -1., 7, 8, 11, 12, 11, 8, 7, -1., -1.], + [-1, -1., 8, 9, 10, 16, 10, 9, 10, 7, -1.], + [-1, -1., -1., 12, 16, 20, 16, 12, 11, -1., -1.], + [-1, -1., -1., -1., 22, 24, 22, 20, 18, -1., -1.], + [-1, -1., -1., -1., -1., 28, 26, 25, -1., -1., -1.], + ] + ) + z = np.ma.masked_less(matrix, 0) + norm = mcolors.Normalize(vmax=z.max(), vmin=z.min()) + colors = mpl.colormaps["plasma"](norm(z)) + ax.plot_surface(x, y, z, facecolors=colors) + ax.view_init(30, -80, 0) + + +@check_figures_equal(extensions=["png"]) +def test_plot_scatter_masks(fig_test, fig_ref): + x = np.linspace(0, 10, 100) + y = np.linspace(0, 10, 100) + z = np.sin(x) * np.cos(y) + mask = z > 0 + + z_masked = np.ma.array(z, mask=mask) + ax_test = fig_test.add_subplot(projection='3d') + ax_test.scatter(x, y, z_masked) + ax_test.plot(x, y, z_masked) + + x[mask] = y[mask] = z[mask] = np.nan + ax_ref = fig_ref.add_subplot(projection='3d') + ax_ref.scatter(x, y, z) + ax_ref.plot(x, y, z) + + +@check_figures_equal(extensions=["png"]) +def test_plot_surface_None_arg(fig_test, fig_ref): + x, y = np.meshgrid(np.arange(5), np.arange(5)) + z = x + y + ax_test = fig_test.add_subplot(projection='3d') + ax_test.plot_surface(x, y, z, facecolors=None) + ax_ref = fig_ref.add_subplot(projection='3d') + ax_ref.plot_surface(x, y, z) + + +@mpl3d_image_comparison(['surface3d_masked_strides.png'], style='mpl20') +def test_surface3d_masked_strides(): + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + + x, y = np.mgrid[-6:6.1:1, -6:6.1:1] + z = np.ma.masked_less(x * y, 2) + + ax.plot_surface(x, y, z, rstride=4, cstride=4) + ax.view_init(60, -45, 0) + + +@mpl3d_image_comparison(['text3d.png'], remove_text=False, style='mpl20') +def test_text3d(): + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + + zdirs = (None, 'x', 'y', 'z', (1, 1, 0), (1, 1, 1)) + xs = (2, 6, 4, 9, 7, 2) + ys = (6, 4, 8, 7, 2, 2) + zs = (4, 2, 5, 6, 1, 7) + + for zdir, x, y, z in zip(zdirs, xs, ys, zs): + label = '(%d, %d, %d), dir=%s' % (x, y, z, zdir) + ax.text(x, y, z, label, zdir) + + ax.text(1, 1, 1, "red", color='red') + ax.text2D(0.05, 0.95, "2D Text", transform=ax.transAxes) + plt.rcParams['axes3d.automargin'] = True # Remove when image is regenerated + ax.set_xlim3d(0, 10) + ax.set_ylim3d(0, 10) + ax.set_zlim3d(0, 10) + ax.set_xlabel('X axis') + ax.set_ylabel('Y axis') + ax.set_zlabel('Z axis') + + +@check_figures_equal(extensions=['png']) +def test_text3d_modification(fig_ref, fig_test): + # Modifying the Text position after the fact should work the same as + # setting it directly. + zdirs = (None, 'x', 'y', 'z', (1, 1, 0), (1, 1, 1)) + xs = (2, 6, 4, 9, 7, 2) + ys = (6, 4, 8, 7, 2, 2) + zs = (4, 2, 5, 6, 1, 7) + + ax_test = fig_test.add_subplot(projection='3d') + ax_test.set_xlim3d(0, 10) + ax_test.set_ylim3d(0, 10) + ax_test.set_zlim3d(0, 10) + for zdir, x, y, z in zip(zdirs, xs, ys, zs): + t = ax_test.text(0, 0, 0, f'({x}, {y}, {z}), dir={zdir}') + t.set_position_3d((x, y, z), zdir=zdir) + + ax_ref = fig_ref.add_subplot(projection='3d') + ax_ref.set_xlim3d(0, 10) + ax_ref.set_ylim3d(0, 10) + ax_ref.set_zlim3d(0, 10) + for zdir, x, y, z in zip(zdirs, xs, ys, zs): + ax_ref.text(x, y, z, f'({x}, {y}, {z}), dir={zdir}', zdir=zdir) + + +@mpl3d_image_comparison(['trisurf3d.png'], tol=0.061, style='mpl20') +def test_trisurf3d(): + n_angles = 36 + n_radii = 8 + radii = np.linspace(0.125, 1.0, n_radii) + angles = np.linspace(0, 2*np.pi, n_angles, endpoint=False) + angles = np.repeat(angles[..., np.newaxis], n_radii, axis=1) + angles[:, 1::2] += np.pi/n_angles + + x = np.append(0, (radii*np.cos(angles)).flatten()) + y = np.append(0, (radii*np.sin(angles)).flatten()) + z = np.sin(-x*y) + + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + ax.plot_trisurf(x, y, z, cmap=cm.jet, linewidth=0.2) + + +@mpl3d_image_comparison(['trisurf3d_shaded.png'], tol=0.03, style='mpl20') +def test_trisurf3d_shaded(): + n_angles = 36 + n_radii = 8 + radii = np.linspace(0.125, 1.0, n_radii) + angles = np.linspace(0, 2*np.pi, n_angles, endpoint=False) + angles = np.repeat(angles[..., np.newaxis], n_radii, axis=1) + angles[:, 1::2] += np.pi/n_angles + + x = np.append(0, (radii*np.cos(angles)).flatten()) + y = np.append(0, (radii*np.sin(angles)).flatten()) + z = np.sin(-x*y) + + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + ax.plot_trisurf(x, y, z, color=[1, 0.5, 0], linewidth=0.2) + + +@mpl3d_image_comparison(['wireframe3d.png'], style='mpl20') +def test_wireframe3d(): + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + X, Y, Z = axes3d.get_test_data(0.05) + ax.plot_wireframe(X, Y, Z, rcount=13, ccount=13) + + +@mpl3d_image_comparison(['wireframe3dzerocstride.png'], style='mpl20') +def test_wireframe3dzerocstride(): + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + X, Y, Z = axes3d.get_test_data(0.05) + ax.plot_wireframe(X, Y, Z, rcount=13, ccount=0) + + +@mpl3d_image_comparison(['wireframe3dzerorstride.png'], style='mpl20') +def test_wireframe3dzerorstride(): + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + X, Y, Z = axes3d.get_test_data(0.05) + ax.plot_wireframe(X, Y, Z, rstride=0, cstride=10) + + +def test_wireframe3dzerostrideraises(): + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + X, Y, Z = axes3d.get_test_data(0.05) + with pytest.raises(ValueError): + ax.plot_wireframe(X, Y, Z, rstride=0, cstride=0) + + +def test_mixedsamplesraises(): + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + X, Y, Z = axes3d.get_test_data(0.05) + with pytest.raises(ValueError): + ax.plot_wireframe(X, Y, Z, rstride=10, ccount=50) + with pytest.raises(ValueError): + ax.plot_surface(X, Y, Z, cstride=50, rcount=10) + + +# remove tolerance when regenerating the test image +@mpl3d_image_comparison(['quiver3d.png'], style='mpl20', tol=0.003) +def test_quiver3d(): + plt.rcParams['axes3d.automargin'] = True # Remove when image is regenerated + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + pivots = ['tip', 'middle', 'tail'] + colors = ['tab:blue', 'tab:orange', 'tab:green'] + for i, (pivot, color) in enumerate(zip(pivots, colors)): + x, y, z = np.meshgrid([-0.5, 0.5], [-0.5, 0.5], [-0.5, 0.5]) + u = -x + v = -y + w = -z + # Offset each set in z direction + z += 2 * i + ax.quiver(x, y, z, u, v, w, length=1, pivot=pivot, color=color) + ax.scatter(x, y, z, color=color) + + ax.set_xlim(-3, 3) + ax.set_ylim(-3, 3) + ax.set_zlim(-1, 5) + + +@check_figures_equal(extensions=["png"]) +def test_quiver3d_empty(fig_test, fig_ref): + fig_ref.add_subplot(projection='3d') + x = y = z = u = v = w = [] + ax = fig_test.add_subplot(projection='3d') + ax.quiver(x, y, z, u, v, w, length=0.1, pivot='tip', normalize=True) + + +@mpl3d_image_comparison(['quiver3d_masked.png'], style='mpl20') +def test_quiver3d_masked(): + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + + # Using mgrid here instead of ogrid because masked_where doesn't + # seem to like broadcasting very much... + x, y, z = np.mgrid[-1:0.8:10j, -1:0.8:10j, -1:0.6:3j] + + u = np.sin(np.pi * x) * np.cos(np.pi * y) * np.cos(np.pi * z) + v = -np.cos(np.pi * x) * np.sin(np.pi * y) * np.cos(np.pi * z) + w = (2/3)**0.5 * np.cos(np.pi * x) * np.cos(np.pi * y) * np.sin(np.pi * z) + u = np.ma.masked_where((-0.4 < x) & (x < 0.1), u, copy=False) + v = np.ma.masked_where((0.1 < y) & (y < 0.7), v, copy=False) + + ax.quiver(x, y, z, u, v, w, length=0.1, pivot='tip', normalize=True) + + +@mpl3d_image_comparison(['quiver3d_colorcoded.png'], style='mpl20') +def test_quiver3d_colorcoded(): + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + + x = y = dx = dz = np.zeros(10) + z = dy = np.arange(10.) + + color = plt.cm.Reds(dy/dy.max()) + ax.quiver(x, y, z, dx, dy, dz, colors=color) + ax.set_ylim(0, 10) + + +def test_patch_modification(): + fig = plt.figure() + ax = fig.add_subplot(projection="3d") + circle = Circle((0, 0)) + ax.add_patch(circle) + art3d.patch_2d_to_3d(circle) + circle.set_facecolor((1.0, 0.0, 0.0, 1)) + + assert mcolors.same_color(circle.get_facecolor(), (1, 0, 0, 1)) + fig.canvas.draw() + assert mcolors.same_color(circle.get_facecolor(), (1, 0, 0, 1)) + + +@check_figures_equal(extensions=['png']) +def test_patch_collection_modification(fig_test, fig_ref): + # Test that modifying Patch3DCollection properties after creation works. + patch1 = Circle((0, 0), 0.05) + patch2 = Circle((0.1, 0.1), 0.03) + facecolors = np.array([[0., 0.5, 0., 1.], [0.5, 0., 0., 0.5]]) + c = art3d.Patch3DCollection([patch1, patch2], linewidths=3) + + ax_test = fig_test.add_subplot(projection='3d') + ax_test.add_collection3d(c) + c.set_edgecolor('C2') + c.set_facecolor(facecolors) + c.set_alpha(0.7) + assert c.get_depthshade() + c.set_depthshade(False) + assert not c.get_depthshade() + + patch1 = Circle((0, 0), 0.05) + patch2 = Circle((0.1, 0.1), 0.03) + facecolors = np.array([[0., 0.5, 0., 1.], [0.5, 0., 0., 0.5]]) + c = art3d.Patch3DCollection([patch1, patch2], linewidths=3, + edgecolor='C2', facecolor=facecolors, + alpha=0.7, depthshade=False) + + ax_ref = fig_ref.add_subplot(projection='3d') + ax_ref.add_collection3d(c) + + +def test_poly3dcollection_verts_validation(): + poly = [[0, 0, 1], [0, 1, 1], [0, 1, 0], [0, 0, 0]] + with pytest.raises(ValueError, match=r'list of \(N, 3\) array-like'): + art3d.Poly3DCollection(poly) # should be Poly3DCollection([poly]) + + poly = np.array(poly, dtype=float) + with pytest.raises(ValueError, match=r'list of \(N, 3\) array-like'): + art3d.Poly3DCollection(poly) # should be Poly3DCollection([poly]) + + +@mpl3d_image_comparison(['poly3dcollection_closed.png'], style='mpl20') +def test_poly3dcollection_closed(): + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + + poly1 = np.array([[0, 0, 1], [0, 1, 1], [0, 0, 0]], float) + poly2 = np.array([[0, 1, 1], [1, 1, 1], [1, 1, 0]], float) + c1 = art3d.Poly3DCollection([poly1], linewidths=3, edgecolor='k', + facecolor=(0.5, 0.5, 1, 0.5), closed=True) + c2 = art3d.Poly3DCollection([poly2], linewidths=3, edgecolor='k', + facecolor=(1, 0.5, 0.5, 0.5), closed=False) + ax.add_collection3d(c1, autolim=False) + ax.add_collection3d(c2, autolim=False) + + +def test_poly_collection_2d_to_3d_empty(): + poly = PolyCollection([]) + art3d.poly_collection_2d_to_3d(poly) + assert isinstance(poly, art3d.Poly3DCollection) + assert poly.get_paths() == [] + + fig, ax = plt.subplots(subplot_kw=dict(projection='3d')) + ax.add_artist(poly) + minz = poly.do_3d_projection() + assert np.isnan(minz) + + # Ensure drawing actually works. + fig.canvas.draw() + + +@mpl3d_image_comparison(['poly3dcollection_alpha.png'], style='mpl20') +def test_poly3dcollection_alpha(): + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + + poly1 = np.array([[0, 0, 1], [0, 1, 1], [0, 0, 0]], float) + poly2 = np.array([[0, 1, 1], [1, 1, 1], [1, 1, 0]], float) + c1 = art3d.Poly3DCollection([poly1], linewidths=3, edgecolor='k', + facecolor=(0.5, 0.5, 1), closed=True) + c1.set_alpha(0.5) + c2 = art3d.Poly3DCollection([poly2], linewidths=3, closed=False) + # Post-creation modification should work. + c2.set_facecolor((1, 0.5, 0.5)) + c2.set_edgecolor('k') + c2.set_alpha(0.5) + ax.add_collection3d(c1, autolim=False) + ax.add_collection3d(c2, autolim=False) + + +@mpl3d_image_comparison(['add_collection3d_zs_array.png'], style='mpl20') +def test_add_collection3d_zs_array(): + theta = np.linspace(-4 * np.pi, 4 * np.pi, 100) + z = np.linspace(-2, 2, 100) + r = z**2 + 1 + x = r * np.sin(theta) + y = r * np.cos(theta) + + points = np.column_stack([x, y, z]).reshape(-1, 1, 3) + segments = np.concatenate([points[:-1], points[1:]], axis=1) + + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + + norm = plt.Normalize(0, 2*np.pi) + # 2D LineCollection from x & y values + lc = LineCollection(segments[:, :, :2], cmap='twilight', norm=norm) + lc.set_array(np.mod(theta, 2*np.pi)) + # Add 2D collection at z values to ax + line = ax.add_collection3d(lc, zs=segments[:, :, 2]) + + assert line is not None + + plt.rcParams['axes3d.automargin'] = True # Remove when image is regenerated + ax.set_xlim(-5, 5) + ax.set_ylim(-4, 6) + ax.set_zlim(-2, 2) + + +@mpl3d_image_comparison(['add_collection3d_zs_scalar.png'], style='mpl20') +def test_add_collection3d_zs_scalar(): + theta = np.linspace(0, 2 * np.pi, 100) + z = 1 + r = z**2 + 1 + x = r * np.sin(theta) + y = r * np.cos(theta) + + points = np.column_stack([x, y]).reshape(-1, 1, 2) + segments = np.concatenate([points[:-1], points[1:]], axis=1) + + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + + norm = plt.Normalize(0, 2*np.pi) + lc = LineCollection(segments, cmap='twilight', norm=norm) + lc.set_array(theta) + line = ax.add_collection3d(lc, zs=z) + + assert line is not None + + plt.rcParams['axes3d.automargin'] = True # Remove when image is regenerated + ax.set_xlim(-5, 5) + ax.set_ylim(-4, 6) + ax.set_zlim(0, 2) + + +def test_line3dCollection_autoscaling(): + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + + lines = [[(0, 0, 0), (1, 4, 2)], + [(1, 1, 3), (2, 0, 2)], + [(1, 0, 4), (1, 4, 5)]] + + lc = art3d.Line3DCollection(lines) + ax.add_collection3d(lc) + assert np.allclose(ax.get_xlim3d(), (-0.041666666666666664, 2.0416666666666665)) + assert np.allclose(ax.get_ylim3d(), (-0.08333333333333333, 4.083333333333333)) + assert np.allclose(ax.get_zlim3d(), (-0.10416666666666666, 5.104166666666667)) + + +def test_poly3dCollection_autoscaling(): + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + poly = np.array([[0, 0, 0], [1, 1, 3], [1, 0, 4]]) + col = art3d.Poly3DCollection([poly]) + ax.add_collection3d(col) + assert np.allclose(ax.get_xlim3d(), (-0.020833333333333332, 1.0208333333333333)) + assert np.allclose(ax.get_ylim3d(), (-0.020833333333333332, 1.0208333333333333)) + assert np.allclose(ax.get_zlim3d(), (-0.0833333333333333, 4.083333333333333)) + + +@mpl3d_image_comparison(['axes3d_labelpad.png'], + remove_text=False, style='mpl20') +def test_axes3d_labelpad(): + fig = plt.figure() + ax = fig.add_axes(Axes3D(fig)) + # labelpad respects rcParams + assert ax.xaxis.labelpad == mpl.rcParams['axes.labelpad'] + # labelpad can be set in set_label + ax.set_xlabel('X LABEL', labelpad=10) + assert ax.xaxis.labelpad == 10 + ax.set_ylabel('Y LABEL') + ax.set_zlabel('Z LABEL', labelpad=20) + assert ax.zaxis.labelpad == 20 + assert ax.get_zlabel() == 'Z LABEL' + # or manually + ax.yaxis.labelpad = 20 + ax.zaxis.labelpad = -40 + + # Tick labels also respect tick.pad (also from rcParams) + for i, tick in enumerate(ax.yaxis.get_major_ticks()): + tick.set_pad(tick.get_pad() + 5 - i * 5) + + +@mpl3d_image_comparison(['axes3d_cla.png'], remove_text=False, style='mpl20') +def test_axes3d_cla(): + # fixed in pull request 4553 + fig = plt.figure() + ax = fig.add_subplot(1, 1, 1, projection='3d') + ax.set_axis_off() + ax.cla() # make sure the axis displayed is 3D (not 2D) + + +@mpl3d_image_comparison(['axes3d_rotated.png'], + remove_text=False, style='mpl20') +def test_axes3d_rotated(): + fig = plt.figure() + ax = fig.add_subplot(1, 1, 1, projection='3d') + ax.view_init(90, 45, 0) # look down, rotated. Should be square + + +def test_plotsurface_1d_raises(): + x = np.linspace(0.5, 10, num=100) + y = np.linspace(0.5, 10, num=100) + X, Y = np.meshgrid(x, y) + z = np.random.randn(100) + + fig = plt.figure(figsize=(14, 6)) + ax = fig.add_subplot(1, 2, 1, projection='3d') + with pytest.raises(ValueError): + ax.plot_surface(X, Y, z) + + +def _test_proj_make_M(): + # eye point + E = np.array([1000, -1000, 2000]) + R = np.array([100, 100, 100]) + V = np.array([0, 0, 1]) + roll = 0 + u, v, w = proj3d._view_axes(E, R, V, roll) + viewM = proj3d._view_transformation_uvw(u, v, w, E) + perspM = proj3d._persp_transformation(100, -100, 1) + M = np.dot(perspM, viewM) + return M + + +def test_proj_transform(): + M = _test_proj_make_M() + invM = np.linalg.inv(M) + + xs = np.array([0, 1, 1, 0, 0, 0, 1, 1, 0, 0]) * 300.0 + ys = np.array([0, 0, 1, 1, 0, 0, 0, 1, 1, 0]) * 300.0 + zs = np.array([0, 0, 0, 0, 0, 1, 1, 1, 1, 1]) * 300.0 + + txs, tys, tzs = proj3d.proj_transform(xs, ys, zs, M) + ixs, iys, izs = proj3d.inv_transform(txs, tys, tzs, invM) + + np.testing.assert_almost_equal(ixs, xs) + np.testing.assert_almost_equal(iys, ys) + np.testing.assert_almost_equal(izs, zs) + + +def _test_proj_draw_axes(M, s=1, *args, **kwargs): + xs = [0, s, 0, 0] + ys = [0, 0, s, 0] + zs = [0, 0, 0, s] + txs, tys, tzs = proj3d.proj_transform(xs, ys, zs, M) + o, ax, ay, az = zip(txs, tys) + lines = [(o, ax), (o, ay), (o, az)] + + fig, ax = plt.subplots(*args, **kwargs) + linec = LineCollection(lines) + ax.add_collection(linec) + for x, y, t in zip(txs, tys, ['o', 'x', 'y', 'z']): + ax.text(x, y, t) + + return fig, ax + + +@mpl3d_image_comparison(['proj3d_axes_cube.png'], style='mpl20') +def test_proj_axes_cube(): + M = _test_proj_make_M() + + ts = '0 1 2 3 0 4 5 6 7 4'.split() + xs = np.array([0, 1, 1, 0, 0, 0, 1, 1, 0, 0]) * 300.0 + ys = np.array([0, 0, 1, 1, 0, 0, 0, 1, 1, 0]) * 300.0 + zs = np.array([0, 0, 0, 0, 0, 1, 1, 1, 1, 1]) * 300.0 + + txs, tys, tzs = proj3d.proj_transform(xs, ys, zs, M) + + fig, ax = _test_proj_draw_axes(M, s=400) + + ax.scatter(txs, tys, c=tzs) + ax.plot(txs, tys, c='r') + for x, y, t in zip(txs, tys, ts): + ax.text(x, y, t) + + plt.rcParams['axes3d.automargin'] = True # Remove when image is regenerated + ax.set_xlim(-0.2, 0.2) + ax.set_ylim(-0.2, 0.2) + + +@mpl3d_image_comparison(['proj3d_axes_cube_ortho.png'], style='mpl20') +def test_proj_axes_cube_ortho(): + E = np.array([200, 100, 100]) + R = np.array([0, 0, 0]) + V = np.array([0, 0, 1]) + roll = 0 + u, v, w = proj3d._view_axes(E, R, V, roll) + viewM = proj3d._view_transformation_uvw(u, v, w, E) + orthoM = proj3d._ortho_transformation(-1, 1) + M = np.dot(orthoM, viewM) + + ts = '0 1 2 3 0 4 5 6 7 4'.split() + xs = np.array([0, 1, 1, 0, 0, 0, 1, 1, 0, 0]) * 100 + ys = np.array([0, 0, 1, 1, 0, 0, 0, 1, 1, 0]) * 100 + zs = np.array([0, 0, 0, 0, 0, 1, 1, 1, 1, 1]) * 100 + + txs, tys, tzs = proj3d.proj_transform(xs, ys, zs, M) + + fig, ax = _test_proj_draw_axes(M, s=150) + + ax.scatter(txs, tys, s=300-tzs) + ax.plot(txs, tys, c='r') + for x, y, t in zip(txs, tys, ts): + ax.text(x, y, t) + + plt.rcParams['axes3d.automargin'] = True # Remove when image is regenerated + ax.set_xlim(-200, 200) + ax.set_ylim(-200, 200) + + +def test_world(): + xmin, xmax = 100, 120 + ymin, ymax = -100, 100 + zmin, zmax = 0.1, 0.2 + M = proj3d.world_transformation(xmin, xmax, ymin, ymax, zmin, zmax) + np.testing.assert_allclose(M, + [[5e-2, 0, 0, -5], + [0, 5e-3, 0, 5e-1], + [0, 0, 1e1, -1], + [0, 0, 0, 1]]) + + +def test_autoscale(): + fig, ax = plt.subplots(subplot_kw={"projection": "3d"}) + assert ax.get_zscale() == 'linear' + ax._view_margin = 0 + ax.margins(x=0, y=.1, z=.2) + ax.plot([0, 1], [0, 1], [0, 1]) + assert ax.get_w_lims() == (0, 1, -.1, 1.1, -.2, 1.2) + ax.autoscale(False) + ax.set_autoscalez_on(True) + ax.plot([0, 2], [0, 2], [0, 2]) + assert ax.get_w_lims() == (0, 1, -.1, 1.1, -.4, 2.4) + ax.autoscale(axis='x') + ax.plot([0, 2], [0, 2], [0, 2]) + assert ax.get_w_lims() == (0, 2, -.1, 1.1, -.4, 2.4) + + +@pytest.mark.parametrize('axis', ('x', 'y', 'z')) +@pytest.mark.parametrize('auto', (True, False, None)) +def test_unautoscale(axis, auto): + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + + x = np.arange(100) + y = np.linspace(-0.1, 0.1, 100) + ax.scatter(x, y) + + get_autoscale_on = getattr(ax, f'get_autoscale{axis}_on') + set_lim = getattr(ax, f'set_{axis}lim') + get_lim = getattr(ax, f'get_{axis}lim') + + post_auto = get_autoscale_on() if auto is None else auto + + set_lim((-0.5, 0.5), auto=auto) + assert post_auto == get_autoscale_on() + fig.canvas.draw() + np.testing.assert_array_equal(get_lim(), (-0.5, 0.5)) + + +@check_figures_equal(extensions=["png"]) +def test_culling(fig_test, fig_ref): + xmins = (-100, -50) + for fig, xmin in zip((fig_test, fig_ref), xmins): + ax = fig.add_subplot(projection='3d') + n = abs(xmin) + 1 + xs = np.linspace(0, xmin, n) + ys = np.ones(n) + zs = np.zeros(n) + ax.plot(xs, ys, zs, 'k') + + ax.set(xlim=(-5, 5), ylim=(-5, 5), zlim=(-5, 5)) + ax.view_init(5, 180, 0) + + +def test_axes3d_focal_length_checks(): + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + with pytest.raises(ValueError): + ax.set_proj_type('persp', focal_length=0) + with pytest.raises(ValueError): + ax.set_proj_type('ortho', focal_length=1) + + +@mpl3d_image_comparison(['axes3d_focal_length.png'], + remove_text=False, style='mpl20') +def test_axes3d_focal_length(): + fig, axs = plt.subplots(1, 2, subplot_kw={'projection': '3d'}) + axs[0].set_proj_type('persp', focal_length=np.inf) + axs[1].set_proj_type('persp', focal_length=0.15) + + +@mpl3d_image_comparison(['axes3d_ortho.png'], remove_text=False, style='mpl20') +def test_axes3d_ortho(): + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + ax.set_proj_type('ortho') + + +@mpl3d_image_comparison(['axes3d_isometric.png'], style='mpl20') +def test_axes3d_isometric(): + from itertools import combinations, product + fig, ax = plt.subplots(subplot_kw=dict( + projection='3d', + proj_type='ortho', + box_aspect=(4, 4, 4) + )) + r = (-1, 1) # stackoverflow.com/a/11156353 + for s, e in combinations(np.array(list(product(r, r, r))), 2): + if abs(s - e).sum() == r[1] - r[0]: + ax.plot3D(*zip(s, e), c='k') + ax.view_init(elev=np.degrees(np.arctan(1. / np.sqrt(2))), azim=-45, roll=0) + ax.grid(True) + + +@check_figures_equal(extensions=["png"]) +def test_axlim_clip(fig_test, fig_ref): + # With axlim clipping + ax = fig_test.add_subplot(projection="3d") + x = np.linspace(0, 1, 11) + y = np.linspace(0, 1, 11) + X, Y = np.meshgrid(x, y) + Z = X + Y + ax.plot_surface(X, Y, Z, facecolor='C1', edgecolors=None, + rcount=50, ccount=50, axlim_clip=True) + # This ax.plot is to cover the extra surface edge which is not clipped out + ax.plot([0.5, 0.5], [0, 1], [0.5, 1.5], + color='k', linewidth=3, zorder=5, axlim_clip=True) + ax.scatter(X.ravel(), Y.ravel(), Z.ravel() + 1, axlim_clip=True) + ax.quiver(X.ravel(), Y.ravel(), Z.ravel() + 2, + 0*X.ravel(), 0*Y.ravel(), 0*Z.ravel() + 1, + arrow_length_ratio=0, axlim_clip=True) + ax.plot(X[0], Y[0], Z[0] + 3, color='C2', axlim_clip=True) + ax.text(1.1, 0.5, 4, 'test', axlim_clip=True) # won't be visible + ax.set(xlim=(0, 0.5), ylim=(0, 1), zlim=(0, 5)) + + # With manual clipping + ax = fig_ref.add_subplot(projection="3d") + idx = (X <= 0.5) + X = X[idx].reshape(11, 6) + Y = Y[idx].reshape(11, 6) + Z = Z[idx].reshape(11, 6) + ax.plot_surface(X, Y, Z, facecolor='C1', edgecolors=None, + rcount=50, ccount=50, axlim_clip=False) + ax.plot([0.5, 0.5], [0, 1], [0.5, 1.5], + color='k', linewidth=3, zorder=5, axlim_clip=False) + ax.scatter(X.ravel(), Y.ravel(), Z.ravel() + 1, axlim_clip=False) + ax.quiver(X.ravel(), Y.ravel(), Z.ravel() + 2, + 0*X.ravel(), 0*Y.ravel(), 0*Z.ravel() + 1, + arrow_length_ratio=0, axlim_clip=False) + ax.plot(X[0], Y[0], Z[0] + 3, color='C2', axlim_clip=False) + ax.set(xlim=(0, 0.5), ylim=(0, 1), zlim=(0, 5)) + + +@pytest.mark.parametrize('value', [np.inf, np.nan]) +@pytest.mark.parametrize(('setter', 'side'), [ + ('set_xlim3d', 'left'), + ('set_xlim3d', 'right'), + ('set_ylim3d', 'bottom'), + ('set_ylim3d', 'top'), + ('set_zlim3d', 'bottom'), + ('set_zlim3d', 'top'), +]) +def test_invalid_axes_limits(setter, side, value): + limit = {side: value} + fig = plt.figure() + obj = fig.add_subplot(projection='3d') + with pytest.raises(ValueError): + getattr(obj, setter)(**limit) + + +class TestVoxels: + @mpl3d_image_comparison(['voxels-simple.png'], style='mpl20') + def test_simple(self): + fig, ax = plt.subplots(subplot_kw={"projection": "3d"}) + + x, y, z = np.indices((5, 4, 3)) + voxels = (x == y) | (y == z) + ax.voxels(voxels) + + @mpl3d_image_comparison(['voxels-edge-style.png'], style='mpl20') + def test_edge_style(self): + fig, ax = plt.subplots(subplot_kw={"projection": "3d"}) + + x, y, z = np.indices((5, 5, 4)) + voxels = ((x - 2)**2 + (y - 2)**2 + (z-1.5)**2) < 2.2**2 + v = ax.voxels(voxels, linewidths=3, edgecolor='C1') + + # change the edge color of one voxel + v[max(v.keys())].set_edgecolor('C2') + + @mpl3d_image_comparison(['voxels-named-colors.png'], style='mpl20') + def test_named_colors(self): + """Test with colors set to a 3D object array of strings.""" + fig, ax = plt.subplots(subplot_kw={"projection": "3d"}) + + x, y, z = np.indices((10, 10, 10)) + voxels = (x == y) | (y == z) + voxels = voxels & ~(x * y * z < 1) + colors = np.full((10, 10, 10), 'C0', dtype=np.object_) + colors[(x < 5) & (y < 5)] = '0.25' + colors[(x + z) < 10] = 'cyan' + ax.voxels(voxels, facecolors=colors) + + @mpl3d_image_comparison(['voxels-rgb-data.png'], style='mpl20') + def test_rgb_data(self): + """Test with colors set to a 4d float array of rgb data.""" + fig, ax = plt.subplots(subplot_kw={"projection": "3d"}) + + x, y, z = np.indices((10, 10, 10)) + voxels = (x == y) | (y == z) + colors = np.zeros((10, 10, 10, 3)) + colors[..., 0] = x / 9 + colors[..., 1] = y / 9 + colors[..., 2] = z / 9 + ax.voxels(voxels, facecolors=colors) + + @mpl3d_image_comparison(['voxels-alpha.png'], style='mpl20') + def test_alpha(self): + fig, ax = plt.subplots(subplot_kw={"projection": "3d"}) + + x, y, z = np.indices((10, 10, 10)) + v1 = x == y + v2 = np.abs(x - y) < 2 + voxels = v1 | v2 + colors = np.zeros((10, 10, 10, 4)) + colors[v2] = [1, 0, 0, 0.5] + colors[v1] = [0, 1, 0, 0.5] + v = ax.voxels(voxels, facecolors=colors) + + assert type(v) is dict + for coord, poly in v.items(): + assert voxels[coord], "faces returned for absent voxel" + assert isinstance(poly, art3d.Poly3DCollection) + + @mpl3d_image_comparison(['voxels-xyz.png'], + tol=0.01, remove_text=False, style='mpl20') + def test_xyz(self): + fig, ax = plt.subplots(subplot_kw={"projection": "3d"}) + + def midpoints(x): + sl = () + for i in range(x.ndim): + x = (x[sl + np.index_exp[:-1]] + + x[sl + np.index_exp[1:]]) / 2.0 + sl += np.index_exp[:] + return x + + # prepare some coordinates, and attach rgb values to each + r, g, b = np.indices((17, 17, 17)) / 16.0 + rc = midpoints(r) + gc = midpoints(g) + bc = midpoints(b) + + # define a sphere about [0.5, 0.5, 0.5] + sphere = (rc - 0.5)**2 + (gc - 0.5)**2 + (bc - 0.5)**2 < 0.5**2 + + # combine the color components + colors = np.zeros(sphere.shape + (3,)) + colors[..., 0] = rc + colors[..., 1] = gc + colors[..., 2] = bc + + # and plot everything + ax.voxels(r, g, b, sphere, + facecolors=colors, + edgecolors=np.clip(2*colors - 0.5, 0, 1), # brighter + linewidth=0.5) + + def test_calling_conventions(self): + x, y, z = np.indices((3, 4, 5)) + filled = np.ones((2, 3, 4)) + + fig, ax = plt.subplots(subplot_kw={"projection": "3d"}) + + # all the valid calling conventions + for kw in (dict(), dict(edgecolor='k')): + ax.voxels(filled, **kw) + ax.voxels(filled=filled, **kw) + ax.voxels(x, y, z, filled, **kw) + ax.voxels(x, y, z, filled=filled, **kw) + + # duplicate argument + with pytest.raises(TypeError, match='voxels'): + ax.voxels(x, y, z, filled, filled=filled) + # missing arguments + with pytest.raises(TypeError, match='voxels'): + ax.voxels(x, y) + # x, y, z are positional only - this passes them on as attributes of + # Poly3DCollection + with pytest.raises(AttributeError, match="keyword argument 'x'") as exec_info: + ax.voxels(filled=filled, x=x, y=y, z=z) + assert exec_info.value.name == 'x' + + +def test_line3d_set_get_data_3d(): + x, y, z = [0, 1], [2, 3], [4, 5] + x2, y2, z2 = [6, 7], [8, 9], [10, 11] + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + lines = ax.plot(x, y, z) + line = lines[0] + np.testing.assert_array_equal((x, y, z), line.get_data_3d()) + line.set_data_3d(x2, y2, z2) + np.testing.assert_array_equal((x2, y2, z2), line.get_data_3d()) + line.set_xdata(x) + line.set_ydata(y) + line.set_3d_properties(zs=z, zdir='z') + np.testing.assert_array_equal((x, y, z), line.get_data_3d()) + line.set_3d_properties(zs=0, zdir='z') + np.testing.assert_array_equal((x, y, np.zeros_like(z)), line.get_data_3d()) + + +@check_figures_equal(extensions=["png"]) +def test_inverted(fig_test, fig_ref): + # Plot then invert. + ax = fig_test.add_subplot(projection="3d") + ax.plot([1, 1, 10, 10], [1, 10, 10, 10], [1, 1, 1, 10]) + ax.invert_yaxis() + # Invert then plot. + ax = fig_ref.add_subplot(projection="3d") + ax.invert_yaxis() + ax.plot([1, 1, 10, 10], [1, 10, 10, 10], [1, 1, 1, 10]) + + +def test_inverted_cla(): + # GitHub PR #5450. Setting autoscale should reset + # axes to be non-inverted. + fig, ax = plt.subplots(subplot_kw={"projection": "3d"}) + # 1. test that a new axis is not inverted per default + assert not ax.xaxis_inverted() + assert not ax.yaxis_inverted() + assert not ax.zaxis_inverted() + ax.set_xlim(1, 0) + ax.set_ylim(1, 0) + ax.set_zlim(1, 0) + assert ax.xaxis_inverted() + assert ax.yaxis_inverted() + assert ax.zaxis_inverted() + ax.cla() + assert not ax.xaxis_inverted() + assert not ax.yaxis_inverted() + assert not ax.zaxis_inverted() + + +def test_ax3d_tickcolour(): + fig = plt.figure() + ax = Axes3D(fig) + + ax.tick_params(axis='x', colors='red') + ax.tick_params(axis='y', colors='red') + ax.tick_params(axis='z', colors='red') + fig.canvas.draw() + + for tick in ax.xaxis.get_major_ticks(): + assert tick.tick1line._color == 'red' + for tick in ax.yaxis.get_major_ticks(): + assert tick.tick1line._color == 'red' + for tick in ax.zaxis.get_major_ticks(): + assert tick.tick1line._color == 'red' + + +@check_figures_equal(extensions=["png"]) +def test_ticklabel_format(fig_test, fig_ref): + axs = fig_test.subplots(4, 5, subplot_kw={"projection": "3d"}) + for ax in axs.flat: + ax.set_xlim(1e7, 1e7 + 10) + for row, name in zip(axs, ["x", "y", "z", "both"]): + row[0].ticklabel_format( + axis=name, style="plain") + row[1].ticklabel_format( + axis=name, scilimits=(-2, 2)) + row[2].ticklabel_format( + axis=name, useOffset=not mpl.rcParams["axes.formatter.useoffset"]) + row[3].ticklabel_format( + axis=name, useLocale=not mpl.rcParams["axes.formatter.use_locale"]) + row[4].ticklabel_format( + axis=name, + useMathText=not mpl.rcParams["axes.formatter.use_mathtext"]) + + def get_formatters(ax, names): + return [getattr(ax, name).get_major_formatter() for name in names] + + axs = fig_ref.subplots(4, 5, subplot_kw={"projection": "3d"}) + for ax in axs.flat: + ax.set_xlim(1e7, 1e7 + 10) + for row, names in zip( + axs, [["xaxis"], ["yaxis"], ["zaxis"], ["xaxis", "yaxis", "zaxis"]] + ): + for fmt in get_formatters(row[0], names): + fmt.set_scientific(False) + for fmt in get_formatters(row[1], names): + fmt.set_powerlimits((-2, 2)) + for fmt in get_formatters(row[2], names): + fmt.set_useOffset(not mpl.rcParams["axes.formatter.useoffset"]) + for fmt in get_formatters(row[3], names): + fmt.set_useLocale(not mpl.rcParams["axes.formatter.use_locale"]) + for fmt in get_formatters(row[4], names): + fmt.set_useMathText( + not mpl.rcParams["axes.formatter.use_mathtext"]) + + +@check_figures_equal(extensions=["png"]) +def test_quiver3D_smoke(fig_test, fig_ref): + pivot = "middle" + # Make the grid + x, y, z = np.meshgrid( + np.arange(-0.8, 1, 0.2), + np.arange(-0.8, 1, 0.2), + np.arange(-0.8, 1, 0.8) + ) + u = v = w = np.ones_like(x) + + for fig, length in zip((fig_ref, fig_test), (1, 1.0)): + ax = fig.add_subplot(projection="3d") + ax.quiver(x, y, z, u, v, w, length=length, pivot=pivot) + + +@image_comparison(["minor_ticks.png"], style="mpl20") +def test_minor_ticks(): + ax = plt.figure().add_subplot(projection="3d") + ax.set_xticks([0.25], minor=True) + ax.set_xticklabels(["quarter"], minor=True) + ax.set_yticks([0.33], minor=True) + ax.set_yticklabels(["third"], minor=True) + ax.set_zticks([0.50], minor=True) + ax.set_zticklabels(["half"], minor=True) + + +# remove tolerance when regenerating the test image +@mpl3d_image_comparison(['errorbar3d_errorevery.png'], style='mpl20', tol=0.003) +def test_errorbar3d_errorevery(): + """Tests errorevery functionality for 3D errorbars.""" + t = np.arange(0, 2*np.pi+.1, 0.01) + x, y, z = np.sin(t), np.cos(3*t), np.sin(5*t) + + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + + estep = 15 + i = np.arange(t.size) + zuplims = (i % estep == 0) & (i // estep % 3 == 0) + zlolims = (i % estep == 0) & (i // estep % 3 == 2) + + ax.errorbar(x, y, z, 0.2, zuplims=zuplims, zlolims=zlolims, + errorevery=estep) + + +@mpl3d_image_comparison(['errorbar3d.png'], style='mpl20', + tol=0 if platform.machine() == 'x86_64' else 0.02) +def test_errorbar3d(): + """Tests limits, color styling, and legend for 3D errorbars.""" + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + + d = [1, 2, 3, 4, 5] + e = [.5, .5, .5, .5, .5] + ax.errorbar(x=d, y=d, z=d, xerr=e, yerr=e, zerr=e, capsize=3, + zuplims=[False, True, False, True, True], + zlolims=[True, False, False, True, False], + yuplims=True, + ecolor='purple', label='Error lines') + ax.legend() + + +@image_comparison(['stem3d.png'], style='mpl20', tol=0.009) +def test_stem3d(): + plt.rcParams['axes3d.automargin'] = True # Remove when image is regenerated + fig, axs = plt.subplots(2, 3, figsize=(8, 6), + constrained_layout=True, + subplot_kw={'projection': '3d'}) + + theta = np.linspace(0, 2*np.pi) + x = np.cos(theta - np.pi/2) + y = np.sin(theta - np.pi/2) + z = theta + + for ax, zdir in zip(axs[0], ['x', 'y', 'z']): + ax.stem(x, y, z, orientation=zdir) + ax.set_title(f'orientation={zdir}') + + x = np.linspace(-np.pi/2, np.pi/2, 20) + y = np.ones_like(x) + z = np.cos(x) + + for ax, zdir in zip(axs[1], ['x', 'y', 'z']): + markerline, stemlines, baseline = ax.stem( + x, y, z, + linefmt='C4-.', markerfmt='C1D', basefmt='C2', + orientation=zdir) + ax.set_title(f'orientation={zdir}') + markerline.set(markerfacecolor='none', markeredgewidth=2) + baseline.set_linewidth(3) + + +@image_comparison(["equal_box_aspect.png"], style="mpl20") +def test_equal_box_aspect(): + from itertools import product, combinations + + fig = plt.figure() + ax = fig.add_subplot(projection="3d") + + # Make data + u = np.linspace(0, 2 * np.pi, 100) + v = np.linspace(0, np.pi, 100) + x = np.outer(np.cos(u), np.sin(v)) + y = np.outer(np.sin(u), np.sin(v)) + z = np.outer(np.ones_like(u), np.cos(v)) + + # Plot the surface + ax.plot_surface(x, y, z) + + # draw cube + r = [-1, 1] + for s, e in combinations(np.array(list(product(r, r, r))), 2): + if np.sum(np.abs(s - e)) == r[1] - r[0]: + ax.plot3D(*zip(s, e), color="b") + + # Make axes limits + xyzlim = np.column_stack( + [ax.get_xlim3d(), ax.get_ylim3d(), ax.get_zlim3d()] + ) + XYZlim = [min(xyzlim[0]), max(xyzlim[1])] + ax.set_xlim3d(XYZlim) + ax.set_ylim3d(XYZlim) + ax.set_zlim3d(XYZlim) + ax.axis('off') + ax.set_box_aspect((1, 1, 1)) + + with pytest.raises(ValueError, match="Argument zoom ="): + ax.set_box_aspect((1, 1, 1), zoom=-1) + + +def test_colorbar_pos(): + num_plots = 2 + fig, axs = plt.subplots(1, num_plots, figsize=(4, 5), + constrained_layout=True, + subplot_kw={'projection': '3d'}) + for ax in axs: + p_tri = ax.plot_trisurf(np.random.randn(5), np.random.randn(5), + np.random.randn(5)) + + cbar = plt.colorbar(p_tri, ax=axs, orientation='horizontal') + + fig.canvas.draw() + # check that actually on the bottom + assert cbar.ax.get_position().extents[1] < 0.2 + + +def test_inverted_zaxis(): + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + ax.set_zlim(0, 1) + assert not ax.zaxis_inverted() + assert ax.get_zlim() == (0, 1) + assert ax.get_zbound() == (0, 1) + + # Change bound + ax.set_zbound((0, 2)) + assert not ax.zaxis_inverted() + assert ax.get_zlim() == (0, 2) + assert ax.get_zbound() == (0, 2) + + # Change invert + ax.invert_zaxis() + assert ax.zaxis_inverted() + assert ax.get_zlim() == (2, 0) + assert ax.get_zbound() == (0, 2) + + # Set upper bound + ax.set_zbound(upper=1) + assert ax.zaxis_inverted() + assert ax.get_zlim() == (1, 0) + assert ax.get_zbound() == (0, 1) + + # Set lower bound + ax.set_zbound(lower=2) + assert ax.zaxis_inverted() + assert ax.get_zlim() == (2, 1) + assert ax.get_zbound() == (1, 2) + + +def test_set_zlim(): + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + assert np.allclose(ax.get_zlim(), (-1/48, 49/48)) + ax.set_zlim(zmax=2) + assert np.allclose(ax.get_zlim(), (-1/48, 2)) + ax.set_zlim(zmin=1) + assert ax.get_zlim() == (1, 2) + + with pytest.raises( + TypeError, match="Cannot pass both 'lower' and 'min'"): + ax.set_zlim(bottom=0, zmin=1) + with pytest.raises( + TypeError, match="Cannot pass both 'upper' and 'max'"): + ax.set_zlim(top=0, zmax=1) + + +@check_figures_equal(extensions=["png"]) +def test_shared_view(fig_test, fig_ref): + elev, azim, roll = 5, 20, 30 + ax1 = fig_test.add_subplot(131, projection="3d") + ax2 = fig_test.add_subplot(132, projection="3d", shareview=ax1) + ax3 = fig_test.add_subplot(133, projection="3d") + ax3.shareview(ax1) + ax2.view_init(elev=elev, azim=azim, roll=roll, share=True) + + for subplot_num in (131, 132, 133): + ax = fig_ref.add_subplot(subplot_num, projection="3d") + ax.view_init(elev=elev, azim=azim, roll=roll) + + +def test_shared_axes_retick(): + fig = plt.figure() + ax1 = fig.add_subplot(211, projection="3d") + ax2 = fig.add_subplot(212, projection="3d", sharez=ax1) + ax1.plot([0, 1], [0, 1], [0, 2]) + ax2.plot([0, 1], [0, 1], [0, 2]) + ax1.set_zticks([-0.5, 0, 2, 2.5]) + # check that setting ticks on a shared axis is synchronized + assert ax1.get_zlim() == (-0.5, 2.5) + assert ax2.get_zlim() == (-0.5, 2.5) + + +def test_quaternion(): + # 1: + q1 = Quaternion(1, [0, 0, 0]) + assert q1.scalar == 1 + assert (q1.vector == [0, 0, 0]).all + # __neg__: + assert (-q1).scalar == -1 + assert ((-q1).vector == [0, 0, 0]).all + # i, j, k: + qi = Quaternion(0, [1, 0, 0]) + assert qi.scalar == 0 + assert (qi.vector == [1, 0, 0]).all + qj = Quaternion(0, [0, 1, 0]) + assert qj.scalar == 0 + assert (qj.vector == [0, 1, 0]).all + qk = Quaternion(0, [0, 0, 1]) + assert qk.scalar == 0 + assert (qk.vector == [0, 0, 1]).all + # i^2 = j^2 = k^2 = -1: + assert qi*qi == -q1 + assert qj*qj == -q1 + assert qk*qk == -q1 + # identity: + assert q1*qi == qi + assert q1*qj == qj + assert q1*qk == qk + # i*j=k, j*k=i, k*i=j: + assert qi*qj == qk + assert qj*qk == qi + assert qk*qi == qj + assert qj*qi == -qk + assert qk*qj == -qi + assert qi*qk == -qj + # __mul__: + assert (Quaternion(2, [3, 4, 5]) * Quaternion(6, [7, 8, 9]) + == Quaternion(-86, [28, 48, 44])) + # conjugate(): + for q in [q1, qi, qj, qk]: + assert q.conjugate().scalar == q.scalar + assert (q.conjugate().vector == -q.vector).all + assert q.conjugate().conjugate() == q + assert ((q*q.conjugate()).vector == 0).all + # norm: + q0 = Quaternion(0, [0, 0, 0]) + assert q0.norm == 0 + assert q1.norm == 1 + assert qi.norm == 1 + assert qj.norm == 1 + assert qk.norm == 1 + for q in [q0, q1, qi, qj, qk]: + assert q.norm == (q*q.conjugate()).scalar + # normalize(): + for q in [ + Quaternion(2, [0, 0, 0]), + Quaternion(0, [3, 0, 0]), + Quaternion(0, [0, 4, 0]), + Quaternion(0, [0, 0, 5]), + Quaternion(6, [7, 8, 9]) + ]: + assert q.normalize().norm == 1 + # reciprocal(): + for q in [q1, qi, qj, qk]: + assert q*q.reciprocal() == q1 + assert q.reciprocal()*q == q1 + # rotate(): + assert (qi.rotate([1, 2, 3]) == np.array([1, -2, -3])).all + # rotate_from_to(): + for r1, r2, q in [ + ([1, 0, 0], [0, 1, 0], Quaternion(np.sqrt(1/2), [0, 0, np.sqrt(1/2)])), + ([1, 0, 0], [0, 0, 1], Quaternion(np.sqrt(1/2), [0, -np.sqrt(1/2), 0])), + ([1, 0, 0], [1, 0, 0], Quaternion(1, [0, 0, 0])) + ]: + assert Quaternion.rotate_from_to(r1, r2) == q + # rotate_from_to(), special case: + for r1 in [[1, 0, 0], [0, 1, 0], [0, 0, 1], [1, 1, 1]]: + r1 = np.array(r1) + with pytest.warns(UserWarning): + q = Quaternion.rotate_from_to(r1, -r1) + assert np.isclose(q.norm, 1) + assert np.dot(q.vector, r1) == 0 + # from_cardan_angles(), as_cardan_angles(): + for elev, azim, roll in [(0, 0, 0), + (90, 0, 0), (0, 90, 0), (0, 0, 90), + (0, 30, 30), (30, 0, 30), (30, 30, 0), + (47, 11, -24)]: + for mag in [1, 2]: + q = Quaternion.from_cardan_angles( + np.deg2rad(elev), np.deg2rad(azim), np.deg2rad(roll)) + assert np.isclose(q.norm, 1) + q = Quaternion(mag * q.scalar, mag * q.vector) + np.testing.assert_allclose(np.rad2deg(Quaternion.as_cardan_angles(q)), + (elev, azim, roll), atol=1e-6) + + +@pytest.mark.parametrize('style', + ('azel', 'trackball', 'sphere', 'arcball')) +def test_rotate(style): + """Test rotating using the left mouse button.""" + if style == 'azel': + s = 0.5 + else: + s = mpl.rcParams['axes3d.trackballsize'] / 2 + s *= 0.5 + mpl.rcParams['axes3d.trackballborder'] = 0 + with mpl.rc_context({'axes3d.mouserotationstyle': style}): + for roll, dx, dy in [ + [0, 1, 0], + [30, 1, 0], + [0, 0, 1], + [30, 0, 1], + [0, 0.5, np.sqrt(3)/2], + [30, 0.5, np.sqrt(3)/2], + [0, 2, 0]]: + fig = plt.figure() + ax = fig.add_subplot(1, 1, 1, projection='3d') + ax.view_init(0, 0, roll) + ax.figure.canvas.draw() + + # drag mouse to change orientation + ax._button_press( + mock_event(ax, button=MouseButton.LEFT, xdata=0, ydata=0)) + ax._on_move( + mock_event(ax, button=MouseButton.LEFT, + xdata=s*dx*ax._pseudo_w, ydata=s*dy*ax._pseudo_h)) + ax.figure.canvas.draw() + + c = np.sqrt(3)/2 + expectations = { + ('azel', 0, 1, 0): (0, -45, 0), + ('azel', 0, 0, 1): (-45, 0, 0), + ('azel', 0, 0.5, c): (-38.971143, -22.5, 0), + ('azel', 0, 2, 0): (0, -90, 0), + ('azel', 30, 1, 0): (22.5, -38.971143, 30), + ('azel', 30, 0, 1): (-38.971143, -22.5, 30), + ('azel', 30, 0.5, c): (-22.5, -38.971143, 30), + + ('trackball', 0, 1, 0): (0, -28.64789, 0), + ('trackball', 0, 0, 1): (-28.64789, 0, 0), + ('trackball', 0, 0.5, c): (-24.531578, -15.277726, 3.340403), + ('trackball', 0, 2, 0): (0, -180/np.pi, 0), + ('trackball', 30, 1, 0): (13.869588, -25.319385, 26.87008), + ('trackball', 30, 0, 1): (-24.531578, -15.277726, 33.340403), + ('trackball', 30, 0.5, c): (-13.869588, -25.319385, 33.129920), + + ('sphere', 0, 1, 0): (0, -30, 0), + ('sphere', 0, 0, 1): (-30, 0, 0), + ('sphere', 0, 0.5, c): (-25.658906, -16.102114, 3.690068), + ('sphere', 0, 2, 0): (0, -90, 0), + ('sphere', 30, 1, 0): (14.477512, -26.565051, 26.565051), + ('sphere', 30, 0, 1): (-25.658906, -16.102114, 33.690068), + ('sphere', 30, 0.5, c): (-14.477512, -26.565051, 33.434949), + + ('arcball', 0, 1, 0): (0, -60, 0), + ('arcball', 0, 0, 1): (-60, 0, 0), + ('arcball', 0, 0.5, c): (-48.590378, -40.893395, 19.106605), + ('arcball', 0, 2, 0): (0, 180, 0), + ('arcball', 30, 1, 0): (25.658906, -56.309932, 16.102114), + ('arcball', 30, 0, 1): (-48.590378, -40.893395, 49.106605), + ('arcball', 30, 0.5, c): (-25.658906, -56.309932, 43.897886)} + new_elev, new_azim, new_roll = expectations[(style, roll, dx, dy)] + np.testing.assert_allclose((ax.elev, ax.azim, ax.roll), + (new_elev, new_azim, new_roll), atol=1e-6) + + +def test_pan(): + """Test mouse panning using the middle mouse button.""" + + def convert_lim(dmin, dmax): + """Convert min/max limits to center and range.""" + center = (dmin + dmax) / 2 + range_ = dmax - dmin + return center, range_ + + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + ax.scatter(0, 0, 0) + fig.canvas.draw() + + x_center0, x_range0 = convert_lim(*ax.get_xlim3d()) + y_center0, y_range0 = convert_lim(*ax.get_ylim3d()) + z_center0, z_range0 = convert_lim(*ax.get_zlim3d()) + + # move mouse diagonally to pan along all axis. + ax._button_press( + mock_event(ax, button=MouseButton.MIDDLE, xdata=0, ydata=0)) + ax._on_move( + mock_event(ax, button=MouseButton.MIDDLE, xdata=1, ydata=1)) + + x_center, x_range = convert_lim(*ax.get_xlim3d()) + y_center, y_range = convert_lim(*ax.get_ylim3d()) + z_center, z_range = convert_lim(*ax.get_zlim3d()) + + # Ranges have not changed + assert x_range == pytest.approx(x_range0) + assert y_range == pytest.approx(y_range0) + assert z_range == pytest.approx(z_range0) + + # But center positions have + assert x_center != pytest.approx(x_center0) + assert y_center != pytest.approx(y_center0) + assert z_center != pytest.approx(z_center0) + + +@pytest.mark.parametrize("tool,button,key,expected", + [("zoom", MouseButton.LEFT, None, # zoom in + ((0.00, 0.06), (0.01, 0.07), (0.02, 0.08))), + ("zoom", MouseButton.LEFT, 'x', # zoom in + ((-0.01, 0.10), (-0.03, 0.08), (-0.06, 0.06))), + ("zoom", MouseButton.LEFT, 'y', # zoom in + ((-0.07, 0.05), (-0.04, 0.08), (0.00, 0.12))), + ("zoom", MouseButton.RIGHT, None, # zoom out + ((-0.09, 0.15), (-0.08, 0.17), (-0.07, 0.18))), + ("pan", MouseButton.LEFT, None, + ((-0.70, -0.58), (-1.04, -0.91), (-1.27, -1.15))), + ("pan", MouseButton.LEFT, 'x', + ((-0.97, -0.84), (-0.58, -0.46), (-0.06, 0.06))), + ("pan", MouseButton.LEFT, 'y', + ((0.20, 0.32), (-0.51, -0.39), (-1.27, -1.15)))]) +def test_toolbar_zoom_pan(tool, button, key, expected): + # NOTE: The expected zoom values are rough ballparks of moving in the view + # to make sure we are getting the right direction of motion. + # The specific values can and should change if the zoom movement + # scaling factor gets updated. + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + ax.scatter(0, 0, 0) + fig.canvas.draw() + xlim0, ylim0, zlim0 = ax.get_xlim3d(), ax.get_ylim3d(), ax.get_zlim3d() + + # Mouse from (0, 0) to (1, 1) + d0 = (0, 0) + d1 = (1, 1) + # Convert to screen coordinates ("s"). Events are defined only with pixel + # precision, so round the pixel values, and below, check against the + # corresponding xdata/ydata, which are close but not equal to d0/d1. + s0 = ax.transData.transform(d0).astype(int) + s1 = ax.transData.transform(d1).astype(int) + + # Set up the mouse movements + start_event = MouseEvent( + "button_press_event", fig.canvas, *s0, button, key=key) + stop_event = MouseEvent( + "button_release_event", fig.canvas, *s1, button, key=key) + + tb = NavigationToolbar2(fig.canvas) + if tool == "zoom": + tb.zoom() + tb.press_zoom(start_event) + tb.drag_zoom(stop_event) + tb.release_zoom(stop_event) + else: + tb.pan() + tb.press_pan(start_event) + tb.drag_pan(stop_event) + tb.release_pan(stop_event) + + # Should be close, but won't be exact due to screen integer resolution + xlim, ylim, zlim = expected + assert ax.get_xlim3d() == pytest.approx(xlim, abs=0.01) + assert ax.get_ylim3d() == pytest.approx(ylim, abs=0.01) + assert ax.get_zlim3d() == pytest.approx(zlim, abs=0.01) + + # Ensure that back, forward, and home buttons work + tb.back() + assert ax.get_xlim3d() == pytest.approx(xlim0) + assert ax.get_ylim3d() == pytest.approx(ylim0) + assert ax.get_zlim3d() == pytest.approx(zlim0) + + tb.forward() + assert ax.get_xlim3d() == pytest.approx(xlim, abs=0.01) + assert ax.get_ylim3d() == pytest.approx(ylim, abs=0.01) + assert ax.get_zlim3d() == pytest.approx(zlim, abs=0.01) + + tb.home() + assert ax.get_xlim3d() == pytest.approx(xlim0) + assert ax.get_ylim3d() == pytest.approx(ylim0) + assert ax.get_zlim3d() == pytest.approx(zlim0) + + +@mpl.style.context('default') +@check_figures_equal(extensions=["png"]) +def test_scalarmap_update(fig_test, fig_ref): + + x, y, z = np.array(list(itertools.product(*[np.arange(0, 5, 1), + np.arange(0, 5, 1), + np.arange(0, 5, 1)]))).T + c = x + y + + # test + ax_test = fig_test.add_subplot(111, projection='3d') + sc_test = ax_test.scatter(x, y, z, c=c, s=40, cmap='viridis') + # force a draw + fig_test.canvas.draw() + # mark it as "stale" + sc_test.changed() + + # ref + ax_ref = fig_ref.add_subplot(111, projection='3d') + sc_ref = ax_ref.scatter(x, y, z, c=c, s=40, cmap='viridis') + + +def test_subfigure_simple(): + # smoketest that subfigures can work... + fig = plt.figure() + sf = fig.subfigures(1, 2) + ax = sf[0].add_subplot(1, 1, 1, projection='3d') + ax = sf[1].add_subplot(1, 1, 1, projection='3d', label='other') + + +# Update style when regenerating the test image +@image_comparison(baseline_images=['computed_zorder'], remove_text=True, + extensions=['png'], style=('mpl20')) +def test_computed_zorder(): + plt.rcParams['axes3d.automargin'] = True # Remove when image is regenerated + fig = plt.figure() + ax1 = fig.add_subplot(221, projection='3d') + ax2 = fig.add_subplot(222, projection='3d') + ax2.computed_zorder = False + + # create a horizontal plane + corners = ((0, 0, 0), (0, 5, 0), (5, 5, 0), (5, 0, 0)) + for ax in (ax1, ax2): + tri = art3d.Poly3DCollection([corners], + facecolors='white', + edgecolors='black', + zorder=1) + ax.add_collection3d(tri) + + # plot a vector + ax.plot((2, 2), (2, 2), (0, 4), c='red', zorder=2) + + # plot some points + ax.scatter((3, 3), (1, 3), (1, 3), c='red', zorder=10) + + ax.set_xlim((0, 5.0)) + ax.set_ylim((0, 5.0)) + ax.set_zlim((0, 2.5)) + + ax3 = fig.add_subplot(223, projection='3d') + ax4 = fig.add_subplot(224, projection='3d') + ax4.computed_zorder = False + + dim = 10 + X, Y = np.meshgrid((-dim, dim), (-dim, dim)) + Z = np.zeros((2, 2)) + + angle = 0.5 + X2, Y2 = np.meshgrid((-dim, dim), (0, dim)) + Z2 = Y2 * angle + X3, Y3 = np.meshgrid((-dim, dim), (-dim, 0)) + Z3 = Y3 * angle + + r = 7 + M = 1000 + th = np.linspace(0, 2 * np.pi, M) + x, y, z = r * np.cos(th), r * np.sin(th), angle * r * np.sin(th) + for ax in (ax3, ax4): + ax.plot_surface(X2, Y3, Z3, + color='blue', + alpha=0.5, + linewidth=0, + zorder=-1) + ax.plot(x[y < 0], y[y < 0], z[y < 0], + lw=5, + linestyle='--', + color='green', + zorder=0) + + ax.plot_surface(X, Y, Z, + color='red', + alpha=0.5, + linewidth=0, + zorder=1) + + ax.plot(r * np.sin(th), r * np.cos(th), np.zeros(M), + lw=5, + linestyle='--', + color='black', + zorder=2) + + ax.plot_surface(X2, Y2, Z2, + color='blue', + alpha=0.5, + linewidth=0, + zorder=3) + + ax.plot(x[y > 0], y[y > 0], z[y > 0], lw=5, + linestyle='--', + color='green', + zorder=4) + ax.view_init(elev=20, azim=-20, roll=0) + ax.axis('off') + + +def test_format_coord(): + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + x = np.arange(10) + ax.plot(x, np.sin(x)) + xv = 0.1 + yv = 0.1 + fig.canvas.draw() + assert ax.format_coord(xv, yv) == 'x=10.5227, y pane=1.0417, z=0.1444' + + # Modify parameters + ax.view_init(roll=30, vertical_axis="y") + fig.canvas.draw() + assert ax.format_coord(xv, yv) == 'x pane=9.1875, y=0.9761, z=0.1291' + + # Reset parameters + ax.view_init() + fig.canvas.draw() + assert ax.format_coord(xv, yv) == 'x=10.5227, y pane=1.0417, z=0.1444' + + # Check orthographic projection + ax.set_proj_type('ortho') + fig.canvas.draw() + assert ax.format_coord(xv, yv) == 'x=10.8869, y pane=1.0417, z=0.1528' + + # Check non-default perspective projection + ax.set_proj_type('persp', focal_length=0.1) + fig.canvas.draw() + assert ax.format_coord(xv, yv) == 'x=9.0620, y pane=1.0417, z=0.1110' + + +def test_get_axis_position(): + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + x = np.arange(10) + ax.plot(x, np.sin(x)) + fig.canvas.draw() + assert ax.get_axis_position() == (False, True, False) + + +def test_margins(): + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + ax.margins(0.2) + assert ax.margins() == (0.2, 0.2, 0.2) + ax.margins(0.1, 0.2, 0.3) + assert ax.margins() == (0.1, 0.2, 0.3) + ax.margins(x=0) + assert ax.margins() == (0, 0.2, 0.3) + ax.margins(y=0.1) + assert ax.margins() == (0, 0.1, 0.3) + ax.margins(z=0) + assert ax.margins() == (0, 0.1, 0) + + +def test_margin_getters(): + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + ax.margins(0.1, 0.2, 0.3) + assert ax.get_xmargin() == 0.1 + assert ax.get_ymargin() == 0.2 + assert ax.get_zmargin() == 0.3 + + +@pytest.mark.parametrize('err, args, kwargs, match', ( + (ValueError, (-1,), {}, r'margin must be greater than -0\.5'), + (ValueError, (1, -1, 1), {}, r'margin must be greater than -0\.5'), + (ValueError, (1, 1, -1), {}, r'margin must be greater than -0\.5'), + (ValueError, tuple(), {'x': -1}, r'margin must be greater than -0\.5'), + (ValueError, tuple(), {'y': -1}, r'margin must be greater than -0\.5'), + (ValueError, tuple(), {'z': -1}, r'margin must be greater than -0\.5'), + (TypeError, (1, ), {'x': 1}, + 'Cannot pass both positional and keyword'), + (TypeError, (1, ), {'x': 1, 'y': 1, 'z': 1}, + 'Cannot pass both positional and keyword'), + (TypeError, (1, ), {'x': 1, 'y': 1}, + 'Cannot pass both positional and keyword'), + (TypeError, (1, 1), {}, 'Must pass a single positional argument for'), +)) +def test_margins_errors(err, args, kwargs, match): + with pytest.raises(err, match=match): + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + ax.margins(*args, **kwargs) + + +@check_figures_equal(extensions=["png"]) +def test_text_3d(fig_test, fig_ref): + ax = fig_ref.add_subplot(projection="3d") + txt = Text(0.5, 0.5, r'Foo bar $\int$') + art3d.text_2d_to_3d(txt, z=1) + ax.add_artist(txt) + assert txt.get_position_3d() == (0.5, 0.5, 1) + + ax = fig_test.add_subplot(projection="3d") + t3d = art3d.Text3D(0.5, 0.5, 1, r'Foo bar $\int$') + ax.add_artist(t3d) + assert t3d.get_position_3d() == (0.5, 0.5, 1) + + +def test_draw_single_lines_from_Nx1(): + # Smoke test for GH#23459 + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + ax.plot([[0], [1]], [[0], [1]], [[0], [1]]) + + +@check_figures_equal(extensions=["png"]) +def test_pathpatch_3d(fig_test, fig_ref): + ax = fig_ref.add_subplot(projection="3d") + path = Path.unit_rectangle() + patch = PathPatch(path) + art3d.pathpatch_2d_to_3d(patch, z=(0, 0.5, 0.7, 1, 0), zdir='y') + ax.add_artist(patch) + + ax = fig_test.add_subplot(projection="3d") + pp3d = art3d.PathPatch3D(path, zs=(0, 0.5, 0.7, 1, 0), zdir='y') + ax.add_artist(pp3d) + + +@image_comparison(baseline_images=['scatter_spiral.png'], + remove_text=True, + style='mpl20') +def test_scatter_spiral(): + plt.rcParams['axes3d.automargin'] = True # Remove when image is regenerated + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + th = np.linspace(0, 2 * np.pi * 6, 256) + sc = ax.scatter(np.sin(th), np.cos(th), th, s=(1 + th * 5), c=th ** 2) + + # force at least 1 draw! + fig.canvas.draw() + + +def test_Poly3DCollection_get_path(): + # Smoke test to see that get_path does not raise + # See GH#27361 + fig, ax = plt.subplots(subplot_kw={"projection": "3d"}) + p = Circle((0, 0), 1.0) + ax.add_patch(p) + art3d.pathpatch_2d_to_3d(p) + p.get_path() + + +def test_Poly3DCollection_get_facecolor(): + # Smoke test to see that get_facecolor does not raise + # See GH#4067 + y, x = np.ogrid[1:10:100j, 1:10:100j] + z2 = np.cos(x) ** 3 - np.sin(y) ** 2 + fig = plt.figure() + ax = fig.add_subplot(111, projection='3d') + r = ax.plot_surface(x, y, z2, cmap='hot') + r.get_facecolor() + + +def test_Poly3DCollection_get_edgecolor(): + # Smoke test to see that get_edgecolor does not raise + # See GH#4067 + y, x = np.ogrid[1:10:100j, 1:10:100j] + z2 = np.cos(x) ** 3 - np.sin(y) ** 2 + fig = plt.figure() + ax = fig.add_subplot(111, projection='3d') + r = ax.plot_surface(x, y, z2, cmap='hot') + r.get_edgecolor() + + +@pytest.mark.parametrize( + "vertical_axis, proj_expected, axis_lines_expected, tickdirs_expected", + [ + ( + "z", + [ + [0.0, 1.142857, 0.0, -0.571429], + [0.0, 0.0, 0.857143, -0.428571], + [0.0, 0.0, 0.0, -10.0], + [-1.142857, 0.0, 0.0, 10.571429], + ], + [ + ([0.05617978, 0.06329114], [-0.04213483, -0.04746835]), + ([-0.06329114, 0.06329114], [-0.04746835, -0.04746835]), + ([-0.06329114, -0.06329114], [-0.04746835, 0.04746835]), + ], + [1, 0, 0], + ), + ( + "y", + [ + [1.142857, 0.0, 0.0, -0.571429], + [0.0, 0.857143, 0.0, -0.428571], + [0.0, 0.0, 0.0, -10.0], + [0.0, 0.0, -1.142857, 10.571429], + ], + [ + ([-0.06329114, 0.06329114], [0.04746835, 0.04746835]), + ([0.06329114, 0.06329114], [-0.04746835, 0.04746835]), + ([-0.05617978, -0.06329114], [0.04213483, 0.04746835]), + ], + [2, 2, 0], + ), + ( + "x", + [ + [0.0, 0.0, 1.142857, -0.571429], + [0.857143, 0.0, 0.0, -0.428571], + [0.0, 0.0, 0.0, -10.0], + [0.0, -1.142857, 0.0, 10.571429], + ], + [ + ([-0.06329114, -0.06329114], [0.04746835, -0.04746835]), + ([0.06329114, 0.05617978], [0.04746835, 0.04213483]), + ([0.06329114, -0.06329114], [0.04746835, 0.04746835]), + ], + [1, 2, 1], + ), + ], +) +def test_view_init_vertical_axis( + vertical_axis, proj_expected, axis_lines_expected, tickdirs_expected +): + """ + Test the actual projection, axis lines and ticks matches expected values. + + Parameters + ---------- + vertical_axis : str + Axis to align vertically. + proj_expected : ndarray + Expected values from ax.get_proj(). + axis_lines_expected : tuple of arrays + Edgepoints of the axis line. Expected values retrieved according + to ``ax.get_[xyz]axis().line.get_data()``. + tickdirs_expected : list of int + indexes indicating which axis to create a tick line along. + """ + rtol = 2e-06 + ax = plt.subplot(1, 1, 1, projection="3d") + ax.view_init(elev=0, azim=0, roll=0, vertical_axis=vertical_axis) + ax.get_figure().canvas.draw() + + # Assert the projection matrix: + proj_actual = ax.get_proj() + np.testing.assert_allclose(proj_expected, proj_actual, rtol=rtol) + + for i, axis in enumerate([ax.get_xaxis(), ax.get_yaxis(), ax.get_zaxis()]): + # Assert black lines are correctly aligned: + axis_line_expected = axis_lines_expected[i] + axis_line_actual = axis.line.get_data() + np.testing.assert_allclose(axis_line_expected, axis_line_actual, + rtol=rtol) + + # Assert ticks are correctly aligned: + tickdir_expected = tickdirs_expected[i] + tickdir_actual = axis._get_tickdir('default') + np.testing.assert_array_equal(tickdir_expected, tickdir_actual) + + +@pytest.mark.parametrize("vertical_axis", ["x", "y", "z"]) +def test_on_move_vertical_axis(vertical_axis: str) -> None: + """ + Test vertical axis is respected when rotating the plot interactively. + """ + ax = plt.subplot(1, 1, 1, projection="3d") + ax.view_init(elev=0, azim=0, roll=0, vertical_axis=vertical_axis) + ax.get_figure().canvas.draw() + + proj_before = ax.get_proj() + event_click = mock_event(ax, button=MouseButton.LEFT, xdata=0, ydata=1) + ax._button_press(event_click) + + event_move = mock_event(ax, button=MouseButton.LEFT, xdata=0.5, ydata=0.8) + ax._on_move(event_move) + + assert ax._axis_names.index(vertical_axis) == ax._vertical_axis + + # Make sure plot has actually moved: + proj_after = ax.get_proj() + np.testing.assert_raises( + AssertionError, np.testing.assert_allclose, proj_before, proj_after + ) + + +@pytest.mark.parametrize( + "vertical_axis, aspect_expected", + [ + ("x", [1.190476, 0.892857, 1.190476]), + ("y", [0.892857, 1.190476, 1.190476]), + ("z", [1.190476, 1.190476, 0.892857]), + ], +) +def test_set_box_aspect_vertical_axis(vertical_axis, aspect_expected): + ax = plt.subplot(1, 1, 1, projection="3d") + ax.view_init(elev=0, azim=0, roll=0, vertical_axis=vertical_axis) + ax.get_figure().canvas.draw() + + ax.set_box_aspect(None) + + np.testing.assert_allclose(aspect_expected, ax._box_aspect, rtol=1e-6) + + +@image_comparison(baseline_images=['arc_pathpatch.png'], + remove_text=True, + style='mpl20') +def test_arc_pathpatch(): + ax = plt.subplot(1, 1, 1, projection="3d") + a = mpatch.Arc((0.5, 0.5), width=0.5, height=0.9, + angle=20, theta1=10, theta2=130) + ax.add_patch(a) + art3d.pathpatch_2d_to_3d(a, z=0, zdir='z') + + +@image_comparison(baseline_images=['panecolor_rcparams.png'], + remove_text=True, + style='mpl20') +def test_panecolor_rcparams(): + with plt.rc_context({'axes3d.xaxis.panecolor': 'r', + 'axes3d.yaxis.panecolor': 'g', + 'axes3d.zaxis.panecolor': 'b'}): + fig = plt.figure(figsize=(1, 1)) + fig.add_subplot(projection='3d') + + +@check_figures_equal(extensions=["png"]) +def test_mutating_input_arrays_y_and_z(fig_test, fig_ref): + """ + Test to see if the `z` axis does not get mutated + after a call to `Axes3D.plot` + + test cases came from GH#8990 + """ + ax1 = fig_test.add_subplot(111, projection='3d') + x = [1, 2, 3] + y = [0.0, 0.0, 0.0] + z = [0.0, 0.0, 0.0] + ax1.plot(x, y, z, 'o-') + + # mutate y,z to get a nontrivial line + y[:] = [1, 2, 3] + z[:] = [1, 2, 3] + + # draw the same plot without mutating x and y + ax2 = fig_ref.add_subplot(111, projection='3d') + x = [1, 2, 3] + y = [0.0, 0.0, 0.0] + z = [0.0, 0.0, 0.0] + ax2.plot(x, y, z, 'o-') + + +def test_scatter_masked_color(): + """ + Test color parameter usage with non-finite coordinate arrays. + + GH#26236 + """ + + x = [np.nan, 1, 2, 1] + y = [0, np.inf, 2, 1] + z = [0, 1, -np.inf, 1] + colors = [ + [0.0, 0.0, 0.0, 1], + [0.0, 0.0, 0.0, 1], + [0.0, 0.0, 0.0, 1], + [0.0, 0.0, 0.0, 1] + ] + + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + path3d = ax.scatter(x, y, z, color=colors) + + # Assert sizes' equality + assert len(path3d.get_offsets()) ==\ + len(super(type(path3d), path3d).get_facecolors()) + + +@mpl3d_image_comparison(['surface3d_zsort_inf.png'], style='mpl20') +def test_surface3d_zsort_inf(): + plt.rcParams['axes3d.automargin'] = True # Remove when image is regenerated + fig = plt.figure() + ax = fig.add_subplot(projection='3d') + + x, y = np.mgrid[-2:2:0.1, -2:2:0.1] + z = np.sin(x)**2 + np.cos(y)**2 + z[x.shape[0] // 2:, x.shape[1] // 2:] = np.inf + + ax.plot_surface(x, y, z, cmap='jet') + ax.view_init(elev=45, azim=145) + + +def test_Poly3DCollection_init_value_error(): + # smoke test to ensure the input check works + # GH#26420 + with pytest.raises(ValueError, + match='You must provide facecolors, edgecolors, ' + 'or both for shade to work.'): + poly = np.array([[0, 0, 1], [0, 1, 1], [0, 0, 0]], float) + c = art3d.Poly3DCollection([poly], shade=True) + + +def test_ndarray_color_kwargs_value_error(): + # smoke test + # ensures ndarray can be passed to color in kwargs for 3d projection plot + fig = plt.figure() + ax = fig.add_subplot(111, projection='3d') + ax.scatter(1, 0, 0, color=np.array([0, 0, 0, 1])) + fig.canvas.draw() diff --git a/lib/python3.12/site-packages/mpl_toolkits/mplot3d/tests/test_legend3d.py b/lib/python3.12/site-packages/mpl_toolkits/mplot3d/tests/test_legend3d.py new file mode 100644 index 0000000000000000000000000000000000000000..7fd676df1e31591f343a4a1bd27c27b98d564949 --- /dev/null +++ b/lib/python3.12/site-packages/mpl_toolkits/mplot3d/tests/test_legend3d.py @@ -0,0 +1,117 @@ +import platform + +import numpy as np + +import matplotlib as mpl +from matplotlib.colors import same_color +from matplotlib.testing.decorators import image_comparison +import matplotlib.pyplot as plt +from mpl_toolkits.mplot3d import art3d + + +@image_comparison(['legend_plot.png'], remove_text=True, style='mpl20') +def test_legend_plot(): + fig, ax = plt.subplots(subplot_kw=dict(projection='3d')) + x = np.arange(10) + ax.plot(x, 5 - x, 'o', zdir='y', label='z=1') + ax.plot(x, x - 5, 'o', zdir='y', label='z=-1') + ax.legend() + + +@image_comparison(['legend_bar.png'], remove_text=True, style='mpl20') +def test_legend_bar(): + fig, ax = plt.subplots(subplot_kw=dict(projection='3d')) + x = np.arange(10) + b1 = ax.bar(x, x, zdir='y', align='edge', color='m') + b2 = ax.bar(x, x[::-1], zdir='x', align='edge', color='g') + ax.legend([b1[0], b2[0]], ['up', 'down']) + + +@image_comparison(['fancy.png'], remove_text=True, style='mpl20', + tol=0 if platform.machine() == 'x86_64' else 0.011) +def test_fancy(): + fig, ax = plt.subplots(subplot_kw=dict(projection='3d')) + ax.plot(np.arange(10), np.full(10, 5), np.full(10, 5), 'o--', label='line') + ax.scatter(np.arange(10), np.arange(10, 0, -1), label='scatter') + ax.errorbar(np.full(10, 5), np.arange(10), np.full(10, 10), + xerr=0.5, zerr=0.5, label='errorbar') + ax.legend(loc='lower left', ncols=2, title='My legend', numpoints=1) + + +def test_linecollection_scaled_dashes(): + lines1 = [[(0, .5), (.5, 1)], [(.3, .6), (.2, .2)]] + lines2 = [[[0.7, .2], [.8, .4]], [[.5, .7], [.6, .1]]] + lines3 = [[[0.6, .2], [.8, .4]], [[.5, .7], [.1, .1]]] + lc1 = art3d.Line3DCollection(lines1, linestyles="--", lw=3) + lc2 = art3d.Line3DCollection(lines2, linestyles="-.") + lc3 = art3d.Line3DCollection(lines3, linestyles=":", lw=.5) + + fig, ax = plt.subplots(subplot_kw=dict(projection='3d')) + ax.add_collection(lc1) + ax.add_collection(lc2) + ax.add_collection(lc3) + + leg = ax.legend([lc1, lc2, lc3], ['line1', 'line2', 'line 3']) + h1, h2, h3 = leg.legend_handles + + for oh, lh in zip((lc1, lc2, lc3), (h1, h2, h3)): + assert oh.get_linestyles()[0] == lh._dash_pattern + + +def test_handlerline3d(): + # Test marker consistency for monolithic Line3D legend handler. + fig, ax = plt.subplots(subplot_kw=dict(projection='3d')) + ax.scatter([0, 1], [0, 1], marker="v") + handles = [art3d.Line3D([0], [0], [0], marker="v")] + leg = ax.legend(handles, ["Aardvark"], numpoints=1) + assert handles[0].get_marker() == leg.legend_handles[0].get_marker() + + +def test_contour_legend_elements(): + x, y = np.mgrid[1:10, 1:10] + h = x * y + colors = ['blue', '#00FF00', 'red'] + + fig, ax = plt.subplots(subplot_kw=dict(projection='3d')) + cs = ax.contour(x, y, h, levels=[10, 30, 50], colors=colors, extend='both') + + artists, labels = cs.legend_elements() + assert labels == ['$x = 10.0$', '$x = 30.0$', '$x = 50.0$'] + assert all(isinstance(a, mpl.lines.Line2D) for a in artists) + assert all(same_color(a.get_color(), c) + for a, c in zip(artists, colors)) + + +def test_contourf_legend_elements(): + x, y = np.mgrid[1:10, 1:10] + h = x * y + + fig, ax = plt.subplots(subplot_kw=dict(projection='3d')) + cs = ax.contourf(x, y, h, levels=[10, 30, 50], + colors=['#FFFF00', '#FF00FF', '#00FFFF'], + extend='both') + cs.cmap.set_over('red') + cs.cmap.set_under('blue') + cs.changed() + artists, labels = cs.legend_elements() + assert labels == ['$x \\leq -1e+250s$', + '$10.0 < x \\leq 30.0$', + '$30.0 < x \\leq 50.0$', + '$x > 1e+250s$'] + expected_colors = ('blue', '#FFFF00', '#FF00FF', 'red') + assert all(isinstance(a, mpl.patches.Rectangle) for a in artists) + assert all(same_color(a.get_facecolor(), c) + for a, c in zip(artists, expected_colors)) + + +def test_legend_Poly3dCollection(): + + verts = np.asarray([[0, 0, 0], [0, 1, 1], [1, 0, 1]]) + mesh = art3d.Poly3DCollection([verts], label="surface") + + fig, ax = plt.subplots(subplot_kw={"projection": "3d"}) + mesh.set_edgecolor('k') + handle = ax.add_collection3d(mesh) + leg = ax.legend() + assert (leg.legend_handles[0].get_facecolor() + == handle.get_facecolor()).all() diff --git a/lib/python3.12/site-packages/numpy-1.26.4.dist-info/INSTALLER b/lib/python3.12/site-packages/numpy-1.26.4.dist-info/INSTALLER new file mode 100644 index 0000000000000000000000000000000000000000..a1b589e38a32041e49332e5e81c2d363dc418d68 --- /dev/null +++ b/lib/python3.12/site-packages/numpy-1.26.4.dist-info/INSTALLER @@ -0,0 +1 @@ +pip diff --git a/lib/python3.12/site-packages/numpy-1.26.4.dist-info/LICENSE.txt b/lib/python3.12/site-packages/numpy-1.26.4.dist-info/LICENSE.txt new file mode 100644 index 0000000000000000000000000000000000000000..d226599fd2ec2f3353ffb0c434cd8be1b570ac95 --- /dev/null +++ b/lib/python3.12/site-packages/numpy-1.26.4.dist-info/LICENSE.txt @@ -0,0 +1,971 @@ +Copyright (c) 2005-2023, NumPy Developers. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of the NumPy Developers nor the names of any + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +---- + +The NumPy repository and source distributions bundle several libraries that are +compatibly licensed. We list these here. + +Name: lapack-lite +Files: numpy/linalg/lapack_lite/* +License: BSD-3-Clause + For details, see numpy/linalg/lapack_lite/LICENSE.txt + +Name: tempita +Files: tools/npy_tempita/* +License: MIT + For details, see tools/npy_tempita/license.txt + +Name: dragon4 +Files: numpy/core/src/multiarray/dragon4.c +License: MIT + For license text, see numpy/core/src/multiarray/dragon4.c + +Name: libdivide +Files: numpy/core/include/numpy/libdivide/* +License: Zlib + For license text, see numpy/core/include/numpy/libdivide/LICENSE.txt + + +Note that the following files are vendored in the repository and sdist but not +installed in built numpy packages: + +Name: Meson +Files: vendored-meson/meson/* +License: Apache 2.0 + For license text, see vendored-meson/meson/COPYING + +Name: spin +Files: .spin/cmds.py +License: BSD-3 + For license text, see .spin/LICENSE + +---- + +This binary distribution of NumPy also bundles the following software: + + +Name: OpenBLAS +Files: numpy.libs/libopenblas*.so +Description: bundled as a dynamically linked library +Availability: https://github.com/OpenMathLib/OpenBLAS/ +License: BSD-3-Clause + Copyright (c) 2011-2014, The OpenBLAS Project + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are + met: + + 1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + 3. Neither the name of the OpenBLAS project nor the names of + its contributors may be used to endorse or promote products + derived from this software without specific prior written + permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE + LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE + USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +Name: LAPACK +Files: numpy.libs/libopenblas*.so +Description: bundled in OpenBLAS +Availability: https://github.com/OpenMathLib/OpenBLAS/ +License: BSD-3-Clause-Attribution + Copyright (c) 1992-2013 The University of Tennessee and The University + of Tennessee Research Foundation. All rights + reserved. + Copyright (c) 2000-2013 The University of California Berkeley. All + rights reserved. + Copyright (c) 2006-2013 The University of Colorado Denver. All rights + reserved. + + $COPYRIGHT$ + + Additional copyrights may follow + + $HEADER$ + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are + met: + + - Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + - Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer listed + in this license in the documentation and/or other materials + provided with the distribution. + + - Neither the name of the copyright holders nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + + The copyright holders provide no reassurances that the source code + provided does not infringe any patent, copyright, or any other + intellectual property rights of third parties. The copyright holders + disclaim any liability to any recipient for claims brought against + recipient by any third party for infringement of that parties + intellectual property rights. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +Name: GCC runtime library +Files: numpy.libs/libgfortran*.so +Description: dynamically linked to files compiled with gcc +Availability: https://gcc.gnu.org/git/?p=gcc.git;a=tree;f=libgfortran +License: GPL-3.0-with-GCC-exception + Copyright (C) 2002-2017 Free Software Foundation, Inc. + + Libgfortran is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 3, or (at your option) + any later version. + + Libgfortran is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + Under Section 7 of GPL version 3, you are granted additional + permissions described in the GCC Runtime Library Exception, version + 3.1, as published by the Free Software Foundation. + + You should have received a copy of the GNU General Public License and + a copy of the GCC Runtime Library Exception along with this program; + see the files COPYING3 and COPYING.RUNTIME respectively. If not, see + <http://www.gnu.org/licenses/>. + +---- + +Full text of license texts referred to above follows (that they are +listed below does not necessarily imply the conditions apply to the +present binary release): + +---- + +GCC RUNTIME LIBRARY EXCEPTION + +Version 3.1, 31 March 2009 + +Copyright (C) 2009 Free Software Foundation, Inc. <http://fsf.org/> + +Everyone is permitted to copy and distribute verbatim copies of this +license document, but changing it is not allowed. + +This GCC Runtime Library Exception ("Exception") is an additional +permission under section 7 of the GNU General Public License, version +3 ("GPLv3"). It applies to a given file (the "Runtime Library") that +bears a notice placed by the copyright holder of the file stating that +the file is governed by GPLv3 along with this Exception. + +When you use GCC to compile a program, GCC may combine portions of +certain GCC header files and runtime libraries with the compiled +program. The purpose of this Exception is to allow compilation of +non-GPL (including proprietary) programs to use, in this way, the +header files and runtime libraries covered by this Exception. + +0. Definitions. + +A file is an "Independent Module" if it either requires the Runtime +Library for execution after a Compilation Process, or makes use of an +interface provided by the Runtime Library, but is not otherwise based +on the Runtime Library. + +"GCC" means a version of the GNU Compiler Collection, with or without +modifications, governed by version 3 (or a specified later version) of +the GNU General Public License (GPL) with the option of using any +subsequent versions published by the FSF. + +"GPL-compatible Software" is software whose conditions of propagation, +modification and use would permit combination with GCC in accord with +the license of GCC. + +"Target Code" refers to output from any compiler for a real or virtual +target processor architecture, in executable form or suitable for +input to an assembler, loader, linker and/or execution +phase. Notwithstanding that, Target Code does not include data in any +format that is used as a compiler intermediate representation, or used +for producing a compiler intermediate representation. + +The "Compilation Process" transforms code entirely represented in +non-intermediate languages designed for human-written code, and/or in +Java Virtual Machine byte code, into Target Code. Thus, for example, +use of source code generators and preprocessors need not be considered +part of the Compilation Process, since the Compilation Process can be +understood as starting with the output of the generators or +preprocessors. + +A Compilation Process is "Eligible" if it is done using GCC, alone or +with other GPL-compatible software, or if it is done without using any +work based on GCC. For example, using non-GPL-compatible Software to +optimize any GCC intermediate representations would not qualify as an +Eligible Compilation Process. + +1. Grant of Additional Permission. + +You have permission to propagate a work of Target Code formed by +combining the Runtime Library with Independent Modules, even if such +propagation would otherwise violate the terms of GPLv3, provided that +all Target Code was generated by Eligible Compilation Processes. You +may then convey such a combination under terms of your choice, +consistent with the licensing of the Independent Modules. + +2. No Weakening of GCC Copyleft. + +The availability of this Exception does not imply any general +presumption that third-party software is unaffected by the copyleft +requirements of the license of GCC. + +---- + + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/> + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + <one line to give the program's name and a brief idea of what it does.> + Copyright (C) <year> <name of author> + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + <program> Copyright (C) <year> <name of author> + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +<http://www.gnu.org/licenses/>. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +<http://www.gnu.org/philosophy/why-not-lgpl.html>. + +Name: libquadmath +Files: numpy.libs/libquadmath*.so +Description: dynamically linked to files compiled with gcc +Availability: https://gcc.gnu.org/git/?p=gcc.git;a=tree;f=libquadmath +License: LGPL-2.1-or-later + + GCC Quad-Precision Math Library + Copyright (C) 2010-2019 Free Software Foundation, Inc. + Written by Francois-Xavier Coudert <fxcoudert@gcc.gnu.org> + + This file is part of the libquadmath library. + Libquadmath is free software; you can redistribute it and/or + modify it under the terms of the GNU Library General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + Libquadmath is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html diff --git a/lib/python3.12/site-packages/numpy-1.26.4.dist-info/METADATA b/lib/python3.12/site-packages/numpy-1.26.4.dist-info/METADATA new file mode 100644 index 0000000000000000000000000000000000000000..8246dc4ed3bfc5e7ea1f402d2aff1b74ca62e16f --- /dev/null +++ b/lib/python3.12/site-packages/numpy-1.26.4.dist-info/METADATA @@ -0,0 +1,1092 @@ +Metadata-Version: 2.1 +Name: numpy +Version: 1.26.4 +Summary: Fundamental package for array computing in Python +Home-page: https://numpy.org +Author: Travis E. Oliphant et al. +Maintainer-Email: NumPy Developers <numpy-discussion@python.org> +License: Copyright (c) 2005-2023, NumPy Developers. + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are + met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of the NumPy Developers nor the names of any + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + ---- + + The NumPy repository and source distributions bundle several libraries that are + compatibly licensed. We list these here. + + Name: lapack-lite + Files: numpy/linalg/lapack_lite/* + License: BSD-3-Clause + For details, see numpy/linalg/lapack_lite/LICENSE.txt + + Name: tempita + Files: tools/npy_tempita/* + License: MIT + For details, see tools/npy_tempita/license.txt + + Name: dragon4 + Files: numpy/core/src/multiarray/dragon4.c + License: MIT + For license text, see numpy/core/src/multiarray/dragon4.c + + Name: libdivide + Files: numpy/core/include/numpy/libdivide/* + License: Zlib + For license text, see numpy/core/include/numpy/libdivide/LICENSE.txt + + + Note that the following files are vendored in the repository and sdist but not + installed in built numpy packages: + + Name: Meson + Files: vendored-meson/meson/* + License: Apache 2.0 + For license text, see vendored-meson/meson/COPYING + + Name: spin + Files: .spin/cmds.py + License: BSD-3 + For license text, see .spin/LICENSE + + ---- + + This binary distribution of NumPy also bundles the following software: + + + Name: OpenBLAS + Files: numpy.libs/libopenblas*.so + Description: bundled as a dynamically linked library + Availability: https://github.com/OpenMathLib/OpenBLAS/ + License: BSD-3-Clause + Copyright (c) 2011-2014, The OpenBLAS Project + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are + met: + + 1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in + the documentation and/or other materials provided with the + distribution. + 3. Neither the name of the OpenBLAS project nor the names of + its contributors may be used to endorse or promote products + derived from this software without specific prior written + permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE + LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE + USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + + Name: LAPACK + Files: numpy.libs/libopenblas*.so + Description: bundled in OpenBLAS + Availability: https://github.com/OpenMathLib/OpenBLAS/ + License: BSD-3-Clause-Attribution + Copyright (c) 1992-2013 The University of Tennessee and The University + of Tennessee Research Foundation. All rights + reserved. + Copyright (c) 2000-2013 The University of California Berkeley. All + rights reserved. + Copyright (c) 2006-2013 The University of Colorado Denver. All rights + reserved. + + $COPYRIGHT$ + + Additional copyrights may follow + + $HEADER$ + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are + met: + + - Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + - Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer listed + in this license in the documentation and/or other materials + provided with the distribution. + + - Neither the name of the copyright holders nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + + The copyright holders provide no reassurances that the source code + provided does not infringe any patent, copyright, or any other + intellectual property rights of third parties. The copyright holders + disclaim any liability to any recipient for claims brought against + recipient by any third party for infringement of that parties + intellectual property rights. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + + Name: GCC runtime library + Files: numpy.libs/libgfortran*.so + Description: dynamically linked to files compiled with gcc + Availability: https://gcc.gnu.org/git/?p=gcc.git;a=tree;f=libgfortran + License: GPL-3.0-with-GCC-exception + Copyright (C) 2002-2017 Free Software Foundation, Inc. + + Libgfortran is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 3, or (at your option) + any later version. + + Libgfortran is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + Under Section 7 of GPL version 3, you are granted additional + permissions described in the GCC Runtime Library Exception, version + 3.1, as published by the Free Software Foundation. + + You should have received a copy of the GNU General Public License and + a copy of the GCC Runtime Library Exception along with this program; + see the files COPYING3 and COPYING.RUNTIME respectively. If not, see + <http://www.gnu.org/licenses/>. + + ---- + + Full text of license texts referred to above follows (that they are + listed below does not necessarily imply the conditions apply to the + present binary release): + + ---- + + GCC RUNTIME LIBRARY EXCEPTION + + Version 3.1, 31 March 2009 + + Copyright (C) 2009 Free Software Foundation, Inc. <http://fsf.org/> + + Everyone is permitted to copy and distribute verbatim copies of this + license document, but changing it is not allowed. + + This GCC Runtime Library Exception ("Exception") is an additional + permission under section 7 of the GNU General Public License, version + 3 ("GPLv3"). It applies to a given file (the "Runtime Library") that + bears a notice placed by the copyright holder of the file stating that + the file is governed by GPLv3 along with this Exception. + + When you use GCC to compile a program, GCC may combine portions of + certain GCC header files and runtime libraries with the compiled + program. The purpose of this Exception is to allow compilation of + non-GPL (including proprietary) programs to use, in this way, the + header files and runtime libraries covered by this Exception. + + 0. Definitions. + + A file is an "Independent Module" if it either requires the Runtime + Library for execution after a Compilation Process, or makes use of an + interface provided by the Runtime Library, but is not otherwise based + on the Runtime Library. + + "GCC" means a version of the GNU Compiler Collection, with or without + modifications, governed by version 3 (or a specified later version) of + the GNU General Public License (GPL) with the option of using any + subsequent versions published by the FSF. + + "GPL-compatible Software" is software whose conditions of propagation, + modification and use would permit combination with GCC in accord with + the license of GCC. + + "Target Code" refers to output from any compiler for a real or virtual + target processor architecture, in executable form or suitable for + input to an assembler, loader, linker and/or execution + phase. Notwithstanding that, Target Code does not include data in any + format that is used as a compiler intermediate representation, or used + for producing a compiler intermediate representation. + + The "Compilation Process" transforms code entirely represented in + non-intermediate languages designed for human-written code, and/or in + Java Virtual Machine byte code, into Target Code. Thus, for example, + use of source code generators and preprocessors need not be considered + part of the Compilation Process, since the Compilation Process can be + understood as starting with the output of the generators or + preprocessors. + + A Compilation Process is "Eligible" if it is done using GCC, alone or + with other GPL-compatible software, or if it is done without using any + work based on GCC. For example, using non-GPL-compatible Software to + optimize any GCC intermediate representations would not qualify as an + Eligible Compilation Process. + + 1. Grant of Additional Permission. + + You have permission to propagate a work of Target Code formed by + combining the Runtime Library with Independent Modules, even if such + propagation would otherwise violate the terms of GPLv3, provided that + all Target Code was generated by Eligible Compilation Processes. You + may then convey such a combination under terms of your choice, + consistent with the licensing of the Independent Modules. + + 2. No Weakening of GCC Copyleft. + + The availability of this Exception does not imply any general + presumption that third-party software is unaffected by the copyleft + requirements of the license of GCC. + + ---- + + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/> + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for + software and other kinds of works. + + The licenses for most software and other practical works are designed + to take away your freedom to share and change the works. By contrast, + the GNU General Public License is intended to guarantee your freedom to + share and change all versions of a program--to make sure it remains free + software for all its users. We, the Free Software Foundation, use the + GNU General Public License for most of our software; it applies also to + any other work released this way by its authors. You can apply it to + your programs, too. + + When we speak of free software, we are referring to freedom, not + price. Our General Public Licenses are designed to make sure that you + have the freedom to distribute copies of free software (and charge for + them if you wish), that you receive source code or can get it if you + want it, that you can change the software or use pieces of it in new + free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you + these rights or asking you to surrender the rights. Therefore, you have + certain responsibilities if you distribute copies of the software, or if + you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether + gratis or for a fee, you must pass on to the recipients the same + freedoms that you received. You must make sure that they, too, receive + or can get the source code. And you must show them these terms so they + know their rights. + + Developers that use the GNU GPL protect your rights with two steps: + (1) assert copyright on the software, and (2) offer you this License + giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains + that there is no warranty for this free software. For both users' and + authors' sake, the GPL requires that modified versions be marked as + changed, so that their problems will not be attributed erroneously to + authors of previous versions. + + Some devices are designed to deny users access to install or run + modified versions of the software inside them, although the manufacturer + can do so. This is fundamentally incompatible with the aim of + protecting users' freedom to change the software. The systematic + pattern of such abuse occurs in the area of products for individuals to + use, which is precisely where it is most unacceptable. Therefore, we + have designed this version of the GPL to prohibit the practice for those + products. If such problems arise substantially in other domains, we + stand ready to extend this provision to those domains in future versions + of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. + States should not allow patents to restrict development and use of + software on general-purpose computers, but in those that do, we wish to + avoid the special danger that patents applied to a free program could + make it effectively proprietary. To prevent this, the GPL assures that + patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and + modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of + works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this + License. Each licensee is addressed as "you". "Licensees" and + "recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work + in a fashion requiring copyright permission, other than the making of an + exact copy. The resulting work is called a "modified version" of the + earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based + on the Program. + + To "propagate" a work means to do anything with it that, without + permission, would make you directly or secondarily liable for + infringement under applicable copyright law, except executing it on a + computer or modifying a private copy. Propagation includes copying, + distribution (with or without modification), making available to the + public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other + parties to make or receive copies. Mere interaction with a user through + a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" + to the extent that it includes a convenient and prominently visible + feature that (1) displays an appropriate copyright notice, and (2) + tells the user that there is no warranty for the work (except to the + extent that warranties are provided), that licensees may convey the + work under this License, and how to view a copy of this License. If + the interface presents a list of user commands or options, such as a + menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work + for making modifications to it. "Object code" means any non-source + form of a work. + + A "Standard Interface" means an interface that either is an official + standard defined by a recognized standards body, or, in the case of + interfaces specified for a particular programming language, one that + is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other + than the work as a whole, that (a) is included in the normal form of + packaging a Major Component, but which is not part of that Major + Component, and (b) serves only to enable use of the work with that + Major Component, or to implement a Standard Interface for which an + implementation is available to the public in source code form. A + "Major Component", in this context, means a major essential component + (kernel, window system, and so on) of the specific operating system + (if any) on which the executable work runs, or a compiler used to + produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all + the source code needed to generate, install, and (for an executable + work) run the object code and to modify the work, including scripts to + control those activities. However, it does not include the work's + System Libraries, or general-purpose tools or generally available free + programs which are used unmodified in performing those activities but + which are not part of the work. For example, Corresponding Source + includes interface definition files associated with source files for + the work, and the source code for shared libraries and dynamically + linked subprograms that the work is specifically designed to require, + such as by intimate data communication or control flow between those + subprograms and other parts of the work. + + The Corresponding Source need not include anything that users + can regenerate automatically from other parts of the Corresponding + Source. + + The Corresponding Source for a work in source code form is that + same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of + copyright on the Program, and are irrevocable provided the stated + conditions are met. This License explicitly affirms your unlimited + permission to run the unmodified Program. The output from running a + covered work is covered by this License only if the output, given its + content, constitutes a covered work. This License acknowledges your + rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not + convey, without conditions so long as your license otherwise remains + in force. You may convey covered works to others for the sole purpose + of having them make modifications exclusively for you, or provide you + with facilities for running those works, provided that you comply with + the terms of this License in conveying all material for which you do + not control copyright. Those thus making or running the covered works + for you must do so exclusively on your behalf, under your direction + and control, on terms that prohibit them from making any copies of + your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under + the conditions stated below. Sublicensing is not allowed; section 10 + makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological + measure under any applicable law fulfilling obligations under article + 11 of the WIPO copyright treaty adopted on 20 December 1996, or + similar laws prohibiting or restricting circumvention of such + measures. + + When you convey a covered work, you waive any legal power to forbid + circumvention of technological measures to the extent such circumvention + is effected by exercising rights under this License with respect to + the covered work, and you disclaim any intention to limit operation or + modification of the work as a means of enforcing, against the work's + users, your or third parties' legal rights to forbid circumvention of + technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you + receive it, in any medium, provided that you conspicuously and + appropriately publish on each copy an appropriate copyright notice; + keep intact all notices stating that this License and any + non-permissive terms added in accord with section 7 apply to the code; + keep intact all notices of the absence of any warranty; and give all + recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, + and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to + produce it from the Program, in the form of source code under the + terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent + works, which are not by their nature extensions of the covered work, + and which are not combined with it such as to form a larger program, + in or on a volume of a storage or distribution medium, is called an + "aggregate" if the compilation and its resulting copyright are not + used to limit the access or legal rights of the compilation's users + beyond what the individual works permit. Inclusion of a covered work + in an aggregate does not cause this License to apply to the other + parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms + of sections 4 and 5, provided that you also convey the + machine-readable Corresponding Source under the terms of this License, + in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded + from the Corresponding Source as a System Library, need not be + included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any + tangible personal property which is normally used for personal, family, + or household purposes, or (2) anything designed or sold for incorporation + into a dwelling. In determining whether a product is a consumer product, + doubtful cases shall be resolved in favor of coverage. For a particular + product received by a particular user, "normally used" refers to a + typical or common use of that class of product, regardless of the status + of the particular user or of the way in which the particular user + actually uses, or expects or is expected to use, the product. A product + is a consumer product regardless of whether the product has substantial + commercial, industrial or non-consumer uses, unless such uses represent + the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, + procedures, authorization keys, or other information required to install + and execute modified versions of a covered work in that User Product from + a modified version of its Corresponding Source. The information must + suffice to ensure that the continued functioning of the modified object + code is in no case prevented or interfered with solely because + modification has been made. + + If you convey an object code work under this section in, or with, or + specifically for use in, a User Product, and the conveying occurs as + part of a transaction in which the right of possession and use of the + User Product is transferred to the recipient in perpetuity or for a + fixed term (regardless of how the transaction is characterized), the + Corresponding Source conveyed under this section must be accompanied + by the Installation Information. But this requirement does not apply + if neither you nor any third party retains the ability to install + modified object code on the User Product (for example, the work has + been installed in ROM). + + The requirement to provide Installation Information does not include a + requirement to continue to provide support service, warranty, or updates + for a work that has been modified or installed by the recipient, or for + the User Product in which it has been modified or installed. Access to a + network may be denied when the modification itself materially and + adversely affects the operation of the network or violates the rules and + protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, + in accord with this section must be in a format that is publicly + documented (and with an implementation available to the public in + source code form), and must require no special password or key for + unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this + License by making exceptions from one or more of its conditions. + Additional permissions that are applicable to the entire Program shall + be treated as though they were included in this License, to the extent + that they are valid under applicable law. If additional permissions + apply only to part of the Program, that part may be used separately + under those permissions, but the entire Program remains governed by + this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option + remove any additional permissions from that copy, or from any part of + it. (Additional permissions may be written to require their own + removal in certain cases when you modify the work.) You may place + additional permissions on material, added by you to a covered work, + for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you + add to a covered work, you may (if authorized by the copyright holders of + that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further + restrictions" within the meaning of section 10. If the Program as you + received it, or any part of it, contains a notice stating that it is + governed by this License along with a term that is a further + restriction, you may remove that term. If a license document contains + a further restriction but permits relicensing or conveying under this + License, you may add to a covered work material governed by the terms + of that license document, provided that the further restriction does + not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you + must place, in the relevant source files, a statement of the + additional terms that apply to those files, or a notice indicating + where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the + form of a separately written license, or stated as exceptions; + the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly + provided under this License. Any attempt otherwise to propagate or + modify it is void, and will automatically terminate your rights under + this License (including any patent licenses granted under the third + paragraph of section 11). + + However, if you cease all violation of this License, then your + license from a particular copyright holder is reinstated (a) + provisionally, unless and until the copyright holder explicitly and + finally terminates your license, and (b) permanently, if the copyright + holder fails to notify you of the violation by some reasonable means + prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is + reinstated permanently if the copyright holder notifies you of the + violation by some reasonable means, this is the first time you have + received notice of violation of this License (for any work) from that + copyright holder, and you cure the violation prior to 30 days after + your receipt of the notice. + + Termination of your rights under this section does not terminate the + licenses of parties who have received copies or rights from you under + this License. If your rights have been terminated and not permanently + reinstated, you do not qualify to receive new licenses for the same + material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or + run a copy of the Program. Ancillary propagation of a covered work + occurring solely as a consequence of using peer-to-peer transmission + to receive a copy likewise does not require acceptance. However, + nothing other than this License grants you permission to propagate or + modify any covered work. These actions infringe copyright if you do + not accept this License. Therefore, by modifying or propagating a + covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically + receives a license from the original licensors, to run, modify and + propagate that work, subject to this License. You are not responsible + for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an + organization, or substantially all assets of one, or subdividing an + organization, or merging organizations. If propagation of a covered + work results from an entity transaction, each party to that + transaction who receives a copy of the work also receives whatever + licenses to the work the party's predecessor in interest had or could + give under the previous paragraph, plus a right to possession of the + Corresponding Source of the work from the predecessor in interest, if + the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the + rights granted or affirmed under this License. For example, you may + not impose a license fee, royalty, or other charge for exercise of + rights granted under this License, and you may not initiate litigation + (including a cross-claim or counterclaim in a lawsuit) alleging that + any patent claim is infringed by making, using, selling, offering for + sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this + License of the Program or a work on which the Program is based. The + work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims + owned or controlled by the contributor, whether already acquired or + hereafter acquired, that would be infringed by some manner, permitted + by this License, of making, using, or selling its contributor version, + but do not include claims that would be infringed only as a + consequence of further modification of the contributor version. For + purposes of this definition, "control" includes the right to grant + patent sublicenses in a manner consistent with the requirements of + this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free + patent license under the contributor's essential patent claims, to + make, use, sell, offer for sale, import and otherwise run, modify and + propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express + agreement or commitment, however denominated, not to enforce a patent + (such as an express permission to practice a patent or covenant not to + sue for patent infringement). To "grant" such a patent license to a + party means to make such an agreement or commitment not to enforce a + patent against the party. + + If you convey a covered work, knowingly relying on a patent license, + and the Corresponding Source of the work is not available for anyone + to copy, free of charge and under the terms of this License, through a + publicly available network server or other readily accessible means, + then you must either (1) cause the Corresponding Source to be so + available, or (2) arrange to deprive yourself of the benefit of the + patent license for this particular work, or (3) arrange, in a manner + consistent with the requirements of this License, to extend the patent + license to downstream recipients. "Knowingly relying" means you have + actual knowledge that, but for the patent license, your conveying the + covered work in a country, or your recipient's use of the covered work + in a country, would infringe one or more identifiable patents in that + country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or + arrangement, you convey, or propagate by procuring conveyance of, a + covered work, and grant a patent license to some of the parties + receiving the covered work authorizing them to use, propagate, modify + or convey a specific copy of the covered work, then the patent license + you grant is automatically extended to all recipients of the covered + work and works based on it. + + A patent license is "discriminatory" if it does not include within + the scope of its coverage, prohibits the exercise of, or is + conditioned on the non-exercise of one or more of the rights that are + specifically granted under this License. You may not convey a covered + work if you are a party to an arrangement with a third party that is + in the business of distributing software, under which you make payment + to the third party based on the extent of your activity of conveying + the work, and under which the third party grants, to any of the + parties who would receive the covered work from you, a discriminatory + patent license (a) in connection with copies of the covered work + conveyed by you (or copies made from those copies), or (b) primarily + for and in connection with specific products or compilations that + contain the covered work, unless you entered into that arrangement, + or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting + any implied license or other defenses to infringement that may + otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or + otherwise) that contradict the conditions of this License, they do not + excuse you from the conditions of this License. If you cannot convey a + covered work so as to satisfy simultaneously your obligations under this + License and any other pertinent obligations, then as a consequence you may + not convey it at all. For example, if you agree to terms that obligate you + to collect a royalty for further conveying from those to whom you convey + the Program, the only way you could satisfy both those terms and this + License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have + permission to link or combine any covered work with a work licensed + under version 3 of the GNU Affero General Public License into a single + combined work, and to convey the resulting work. The terms of this + License will continue to apply to the part which is the covered work, + but the special requirements of the GNU Affero General Public License, + section 13, concerning interaction through a network will apply to the + combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of + the GNU General Public License from time to time. Such new versions will + be similar in spirit to the present version, but may differ in detail to + address new problems or concerns. + + Each version is given a distinguishing version number. If the + Program specifies that a certain numbered version of the GNU General + Public License "or any later version" applies to it, you have the + option of following the terms and conditions either of that numbered + version or of any later version published by the Free Software + Foundation. If the Program does not specify a version number of the + GNU General Public License, you may choose any version ever published + by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future + versions of the GNU General Public License can be used, that proxy's + public statement of acceptance of a version permanently authorizes you + to choose that version for the Program. + + Later license versions may give you additional or different + permissions. However, no additional obligations are imposed on any + author or copyright holder as a result of your choosing to follow a + later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY + APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT + HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY + OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, + THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR + PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM + IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF + ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING + WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS + THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY + GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE + USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF + DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD + PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), + EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF + SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided + above cannot be given local legal effect according to their terms, + reviewing courts shall apply local law that most closely approximates + an absolute waiver of all civil liability in connection with the + Program, unless a warranty or assumption of liability accompanies a + copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest + possible use to the public, the best way to achieve this is to make it + free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest + to attach them to the start of each source file to most effectively + state the exclusion of warranty; and each file should have at least + the "copyright" line and a pointer to where the full notice is found. + + <one line to give the program's name and a brief idea of what it does.> + Copyright (C) <year> <name of author> + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. + + Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short + notice like this when it starts in an interactive mode: + + <program> Copyright (C) <year> <name of author> + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + + The hypothetical commands `show w' and `show c' should show the appropriate + parts of the General Public License. Of course, your program's commands + might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, + if any, to sign a "copyright disclaimer" for the program, if necessary. + For more information on this, and how to apply and follow the GNU GPL, see + <http://www.gnu.org/licenses/>. + + The GNU General Public License does not permit incorporating your program + into proprietary programs. If your program is a subroutine library, you + may consider it more useful to permit linking proprietary applications with + the library. If this is what you want to do, use the GNU Lesser General + Public License instead of this License. But first, please read + <http://www.gnu.org/philosophy/why-not-lgpl.html>. + + Name: libquadmath + Files: numpy.libs/libquadmath*.so + Description: dynamically linked to files compiled with gcc + Availability: https://gcc.gnu.org/git/?p=gcc.git;a=tree;f=libquadmath + License: LGPL-2.1-or-later + + GCC Quad-Precision Math Library + Copyright (C) 2010-2019 Free Software Foundation, Inc. + Written by Francois-Xavier Coudert <fxcoudert@gcc.gnu.org> + + This file is part of the libquadmath library. + Libquadmath is free software; you can redistribute it and/or + modify it under the terms of the GNU Library General Public + License as published by the Free Software Foundation; either + version 2.1 of the License, or (at your option) any later version. + + Libquadmath is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + Lesser General Public License for more details. + https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html +Classifier: Development Status :: 5 - Production/Stable +Classifier: Intended Audience :: Science/Research +Classifier: Intended Audience :: Developers +Classifier: License :: OSI Approved :: BSD License +Classifier: Programming Language :: C +Classifier: Programming Language :: Python +Classifier: Programming Language :: Python :: 3 +Classifier: Programming Language :: Python :: 3.9 +Classifier: Programming Language :: Python :: 3.10 +Classifier: Programming Language :: Python :: 3.11 +Classifier: Programming Language :: Python :: 3.12 +Classifier: Programming Language :: Python :: 3 :: Only +Classifier: Programming Language :: Python :: Implementation :: CPython +Classifier: Topic :: Software Development +Classifier: Topic :: Scientific/Engineering +Classifier: Typing :: Typed +Classifier: Operating System :: Microsoft :: Windows +Classifier: Operating System :: POSIX +Classifier: Operating System :: Unix +Classifier: Operating System :: MacOS +Project-URL: Homepage, https://numpy.org +Project-URL: Documentation, https://numpy.org/doc/ +Project-URL: Source, https://github.com/numpy/numpy +Project-URL: Download, https://pypi.org/project/numpy/#files +Project-URL: Tracker, https://github.com/numpy/numpy/issues +Project-URL: Release notes, https://numpy.org/doc/stable/release +Requires-Python: >=3.9 +Description-Content-Type: text/markdown + +<h1 align="center"> +<img src="https://raw.githubusercontent.com/numpy/numpy/main/branding/logo/primary/numpylogo.svg" width="300"> +</h1><br> + + +[![Powered by NumFOCUS](https://img.shields.io/badge/powered%20by-NumFOCUS-orange.svg?style=flat&colorA=E1523D&colorB=007D8A)]( +https://numfocus.org) +[![PyPI Downloads](https://img.shields.io/pypi/dm/numpy.svg?label=PyPI%20downloads)]( +https://pypi.org/project/numpy/) +[![Conda Downloads](https://img.shields.io/conda/dn/conda-forge/numpy.svg?label=Conda%20downloads)]( +https://anaconda.org/conda-forge/numpy) +[![Stack Overflow](https://img.shields.io/badge/stackoverflow-Ask%20questions-blue.svg)]( +https://stackoverflow.com/questions/tagged/numpy) +[![Nature Paper](https://img.shields.io/badge/DOI-10.1038%2Fs41592--019--0686--2-blue)]( +https://doi.org/10.1038/s41586-020-2649-2) +[![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/numpy/numpy/badge)](https://api.securityscorecards.dev/projects/github.com/numpy/numpy) + + +NumPy is the fundamental package for scientific computing with Python. + +- **Website:** https://www.numpy.org +- **Documentation:** https://numpy.org/doc +- **Mailing list:** https://mail.python.org/mailman/listinfo/numpy-discussion +- **Source code:** https://github.com/numpy/numpy +- **Contributing:** https://www.numpy.org/devdocs/dev/index.html +- **Bug reports:** https://github.com/numpy/numpy/issues +- **Report a security vulnerability:** https://tidelift.com/docs/security + +It provides: + +- a powerful N-dimensional array object +- sophisticated (broadcasting) functions +- tools for integrating C/C++ and Fortran code +- useful linear algebra, Fourier transform, and random number capabilities + +Testing: + +NumPy requires `pytest` and `hypothesis`. Tests can then be run after installation with: + + python -c "import numpy, sys; sys.exit(numpy.test() is False)" + +Code of Conduct +---------------------- + +NumPy is a community-driven open source project developed by a diverse group of +[contributors](https://numpy.org/teams/). The NumPy leadership has made a strong +commitment to creating an open, inclusive, and positive community. Please read the +[NumPy Code of Conduct](https://numpy.org/code-of-conduct/) for guidance on how to interact +with others in a way that makes our community thrive. + +Call for Contributions +---------------------- + +The NumPy project welcomes your expertise and enthusiasm! + +Small improvements or fixes are always appreciated. If you are considering larger contributions +to the source code, please contact us through the [mailing +list](https://mail.python.org/mailman/listinfo/numpy-discussion) first. + +Writing code isn’t the only way to contribute to NumPy. You can also: +- review pull requests +- help us stay on top of new and old issues +- develop tutorials, presentations, and other educational materials +- maintain and improve [our website](https://github.com/numpy/numpy.org) +- develop graphic design for our brand assets and promotional materials +- translate website content +- help with outreach and onboard new contributors +- write grant proposals and help with other fundraising efforts + +For more information about the ways you can contribute to NumPy, visit [our website](https://numpy.org/contribute/). +If you’re unsure where to start or how your skills fit in, reach out! You can +ask on the mailing list or here, on GitHub, by opening a new issue or leaving a +comment on a relevant issue that is already open. + +Our preferred channels of communication are all public, but if you’d like to +speak to us in private first, contact our community coordinators at +numpy-team@googlegroups.com or on Slack (write numpy-team@googlegroups.com for +an invitation). + +We also have a biweekly community call, details of which are announced on the +mailing list. You are very welcome to join. + +If you are new to contributing to open source, [this +guide](https://opensource.guide/how-to-contribute/) helps explain why, what, +and how to successfully get involved. diff --git a/lib/python3.12/site-packages/numpy-1.26.4.dist-info/RECORD b/lib/python3.12/site-packages/numpy-1.26.4.dist-info/RECORD new file mode 100644 index 0000000000000000000000000000000000000000..d8b39e3e585335882e5f3c7a9291800abd859fe8 --- /dev/null +++ b/lib/python3.12/site-packages/numpy-1.26.4.dist-info/RECORD @@ -0,0 +1,1201 @@ +../../../bin/f2py,sha256=GKzd5YV7w8L51lzeyv7nPGKPFA6dh-w0StaJCmqnX-Y,252 +numpy-1.26.4.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4 +numpy-1.26.4.dist-info/LICENSE.txt,sha256=EQewyDHpGNTx28KKMxkMdyFe8njUpMQAlXIIh3DUM0o,47721 +numpy-1.26.4.dist-info/METADATA,sha256=sJc0p_7UToS0yBYZNM5TLf8ed57Ggi1BVkTRF_Y4EHA,61041 +numpy-1.26.4.dist-info/RECORD,, +numpy-1.26.4.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +numpy-1.26.4.dist-info/WHEEL,sha256=3qIDcXCk577AXiK3pDifO-gE9U_MYWYGgtD78gLa2_U,137 +numpy-1.26.4.dist-info/entry_points.txt,sha256=zddyYJuUw9Uud7LeLfynXk62_ry0lGihDwCIgugBdZM,144 +numpy.libs/libgfortran-040039e1.so.5.0.0,sha256=FK-zEpsai1C8QKOwggx_EVLqm8EBIaqxUpQ_cFdHKIY,2686065 +numpy.libs/libopenblas64_p-r0-0cf96a72.3.23.dev.so,sha256=klTQhU3XYV4R3ijXca5AiHjKgSOnrCBPIeTMejdswuU,35123345 +numpy.libs/libquadmath-96973f99.so.0.0.0,sha256=k0wi3tDn0WnE1GeIdslgUa3z2UVF2pYvYLQWWbB12js,247609 +numpy/__config__.py,sha256=z0NFqd9D20ShQlKyPTlbfAPWIJFDEJ7aVp3TQ5_vTxU,4902 +numpy/__init__.cython-30.pxd,sha256=yk2a3etxRNlBgj5uLfIho2RYDYDzhRW8oagAG-wzbPI,36690 +numpy/__init__.pxd,sha256=Pa0VYRSeQRSFepQ6ROgZrNtGY5TzBXIddWsMHtK0OkM,35066 +numpy/__init__.py,sha256=Is0VNfoU10729FfMoUn_3ICHX0YL4xO4-JUnP3i8QC4,17005 +numpy/__init__.pyi,sha256=9kK465XL9oS_X3fJLv0Na29NEYnWvtdMhXPtrnF_cG8,154080 +numpy/__pycache__/__config__.cpython-312.pyc,, +numpy/__pycache__/__init__.cpython-312.pyc,, +numpy/__pycache__/_distributor_init.cpython-312.pyc,, +numpy/__pycache__/_globals.cpython-312.pyc,, +numpy/__pycache__/_pytesttester.cpython-312.pyc,, +numpy/__pycache__/conftest.cpython-312.pyc,, +numpy/__pycache__/ctypeslib.cpython-312.pyc,, +numpy/__pycache__/dtypes.cpython-312.pyc,, +numpy/__pycache__/exceptions.cpython-312.pyc,, +numpy/__pycache__/matlib.cpython-312.pyc,, +numpy/__pycache__/version.cpython-312.pyc,, +numpy/_core/__init__.py,sha256=C8_7wbHqUkB35JouY_XKsas1KLpRZ7JHWuZ7VGOPVpU,136 +numpy/_core/__init__.pyi,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +numpy/_core/__pycache__/__init__.cpython-312.pyc,, +numpy/_core/__pycache__/_dtype.cpython-312.pyc,, +numpy/_core/__pycache__/_dtype_ctypes.cpython-312.pyc,, +numpy/_core/__pycache__/_internal.cpython-312.pyc,, +numpy/_core/__pycache__/_multiarray_umath.cpython-312.pyc,, +numpy/_core/__pycache__/multiarray.cpython-312.pyc,, +numpy/_core/__pycache__/umath.cpython-312.pyc,, +numpy/_core/_dtype.py,sha256=vE16-yiwUSYsAIbq7FlEY1GbXZAp8wjADDxJg3eBX-U,126 +numpy/_core/_dtype_ctypes.py,sha256=i5EhoWPUhu4kla3Xu4ZvXF1lVLPiI6Zg4h6o8jaiamo,147 +numpy/_core/_internal.py,sha256=g5ugmqDgUhSlie5-onOctcm4p0gcMHSIRLHVYtFTk1M,135 +numpy/_core/_multiarray_umath.py,sha256=VPtoT2uHnyU3rKL0G27CgmNmB1WRHM0mtc7Y9L85C3U,159 +numpy/_core/multiarray.py,sha256=kZxC_7P3Jwz1RApzQU2QGmqSq4MAEvKmaJEYnAsbSOs,138 +numpy/_core/umath.py,sha256=YcV0cdbGcem6D5P3yX7cR9HGYBrT8VMoAgCBzGwPhgg,123 +numpy/_distributor_init.py,sha256=IKy2THwmu5UgBjtVbwbD9H-Ap8uaUJoPJ2btQ4Jatdo,407 +numpy/_globals.py,sha256=neEdcfLZoHLwber_1Xyrn26LcXy0MrSta03Ze7aKa6g,3094 +numpy/_pyinstaller/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +numpy/_pyinstaller/__pycache__/__init__.cpython-312.pyc,, +numpy/_pyinstaller/__pycache__/hook-numpy.cpython-312.pyc,, +numpy/_pyinstaller/__pycache__/pyinstaller-smoke.cpython-312.pyc,, +numpy/_pyinstaller/__pycache__/test_pyinstaller.cpython-312.pyc,, +numpy/_pyinstaller/hook-numpy.py,sha256=PUQ-mNWje6bFALB-mLVFRPkvbM4JpLXunB6sjBbTy5g,1409 +numpy/_pyinstaller/pyinstaller-smoke.py,sha256=6iL-eHMQaG3rxnS5EgcvrCqElm9aKL07Cjr1FZJSXls,1143 +numpy/_pyinstaller/test_pyinstaller.py,sha256=8K-7QxmfoXCG0NwR0bhIgCNrDjGlrTzWnrR1sR8btgU,1135 +numpy/_pytesttester.py,sha256=lQUTvKVz6kT8b4yiMV-uW-vG9KSv9UzqAmxaEMezTd8,6731 +numpy/_pytesttester.pyi,sha256=OtyXSiuSy8o_78w3QNQRjMLpvvNyEdC0aMsx6T-vRxU,489 +numpy/_typing/__init__.py,sha256=6w9E9V9VaT7vTM-veua8XcySv50Je5qSPJzK9HTocIg,7003 +numpy/_typing/__pycache__/__init__.cpython-312.pyc,, +numpy/_typing/__pycache__/_add_docstring.cpython-312.pyc,, +numpy/_typing/__pycache__/_array_like.cpython-312.pyc,, +numpy/_typing/__pycache__/_char_codes.cpython-312.pyc,, +numpy/_typing/__pycache__/_dtype_like.cpython-312.pyc,, +numpy/_typing/__pycache__/_extended_precision.cpython-312.pyc,, +numpy/_typing/__pycache__/_nbit.cpython-312.pyc,, +numpy/_typing/__pycache__/_nested_sequence.cpython-312.pyc,, +numpy/_typing/__pycache__/_scalars.cpython-312.pyc,, +numpy/_typing/__pycache__/_shape.cpython-312.pyc,, +numpy/_typing/__pycache__/setup.cpython-312.pyc,, +numpy/_typing/_add_docstring.py,sha256=xQhQX372aN_m3XN95CneMxOST2FdPcovR-MXM-9ep58,3922 +numpy/_typing/_array_like.py,sha256=L4gnx2KWG8yYcouz5b9boJIkkFNtOJV6QjcnGCrbnRY,4298 +numpy/_typing/_callable.pyi,sha256=Mf57BwohRn9ye6ixJqjNEnK0gKqnVPE9Gy8vK-6_zxo,11121 +numpy/_typing/_char_codes.py,sha256=LR51O5AUBDbCmJvlMoxyUvsfvb1p7WHrexgtTGtuWTc,5916 +numpy/_typing/_dtype_like.py,sha256=21Uxy0UgIawGM82xjDF_ifMq-nP-Bkhn_LpiK_HvWC4,5661 +numpy/_typing/_extended_precision.py,sha256=dGios-1k-QBGew7YFzONZTzVWxz-aYAaqlccl2_h5Bo,777 +numpy/_typing/_nbit.py,sha256=-EQOShHpB3r30b4RVEcruQRTcTaFAZwtqCJ4BsvpEzA,345 +numpy/_typing/_nested_sequence.py,sha256=5eNaVZAV9tZQLFWHYOuVs336JjoiaWxyZQ7cMKb6m1I,2566 +numpy/_typing/_scalars.py,sha256=eVP8PjlcTIlY7v0fRI3tFXPogWtpLJZ8nFvRRrLjDqs,980 +numpy/_typing/_shape.py,sha256=JPy7jJMkISGFTnkgiEifYM-4xTcjb7JMRkLIIjZLw08,211 +numpy/_typing/_ufunc.pyi,sha256=e74LtOP9e8kkRhvrIJ_RXz9Ua_L43Pd9IixwNwermnM,12638 +numpy/_typing/setup.py,sha256=SE0Q6HPqDjWUfceA4yXgkII8y3z7EiSF0Z-MNwOIyG4,337 +numpy/_utils/__init__.py,sha256=Hhetwsi3eTBe8HdWbG51zXmcrX1DiPLxkYSrslMLYcc,723 +numpy/_utils/__pycache__/__init__.cpython-312.pyc,, +numpy/_utils/__pycache__/_convertions.cpython-312.pyc,, +numpy/_utils/__pycache__/_inspect.cpython-312.pyc,, +numpy/_utils/__pycache__/_pep440.cpython-312.pyc,, +numpy/_utils/_convertions.py,sha256=0xMxdeLOziDmHsRM_8luEh4S-kQdMoMg6GxNDDas69k,329 +numpy/_utils/_inspect.py,sha256=8Ma7QBRwfSWKeK1ShJpFNc7CDhE6fkIE_wr1FxrG1A8,7447 +numpy/_utils/_pep440.py,sha256=Vr7B3QsijR5p6h8YAz2LjNGUyzHUJ5gZ4v26NpZAKDc,14069 +numpy/array_api/__init__.py,sha256=XtttWbDf6Yh0_m4zp-L_us4HKnV3oGwdlB6n-01Q9M8,10375 +numpy/array_api/__pycache__/__init__.cpython-312.pyc,, +numpy/array_api/__pycache__/_array_object.cpython-312.pyc,, +numpy/array_api/__pycache__/_constants.cpython-312.pyc,, +numpy/array_api/__pycache__/_creation_functions.cpython-312.pyc,, +numpy/array_api/__pycache__/_data_type_functions.cpython-312.pyc,, +numpy/array_api/__pycache__/_dtypes.cpython-312.pyc,, +numpy/array_api/__pycache__/_elementwise_functions.cpython-312.pyc,, +numpy/array_api/__pycache__/_indexing_functions.cpython-312.pyc,, +numpy/array_api/__pycache__/_manipulation_functions.cpython-312.pyc,, +numpy/array_api/__pycache__/_searching_functions.cpython-312.pyc,, +numpy/array_api/__pycache__/_set_functions.cpython-312.pyc,, +numpy/array_api/__pycache__/_sorting_functions.cpython-312.pyc,, +numpy/array_api/__pycache__/_statistical_functions.cpython-312.pyc,, +numpy/array_api/__pycache__/_typing.cpython-312.pyc,, +numpy/array_api/__pycache__/_utility_functions.cpython-312.pyc,, +numpy/array_api/__pycache__/linalg.cpython-312.pyc,, +numpy/array_api/__pycache__/setup.cpython-312.pyc,, +numpy/array_api/_array_object.py,sha256=rfCBzE6vUjk4HElQGTVwe6Tw2vxiUx7tmBpQEmm1iBk,43794 +numpy/array_api/_constants.py,sha256=AYayN2jf1Dp5rXZ7WPBdUhtPBo_JMCi-pD9oW5zmFkI,87 +numpy/array_api/_creation_functions.py,sha256=6SqHdzZqHOJFEyWFtqnj6KIKRivrGXxROlgnez_3Mt0,10050 +numpy/array_api/_data_type_functions.py,sha256=P57FOsNdXahNUriVtdldonbvBQrrZkVzxZbcqkR_8AA,6288 +numpy/array_api/_dtypes.py,sha256=kDU1NLvEQN-W2HPmJ2wGPx8jiNkFbrvTCD1T1RT8Pwo,4823 +numpy/array_api/_elementwise_functions.py,sha256=0kGuDX3Ur_Qp6tBMBWTO7LPUxzXNGAlA2SSJhdAp4DU,25992 +numpy/array_api/_indexing_functions.py,sha256=d-gzqzyvR45FQerRYJrbBzCWFnDsZWSI9pggA5QWRO4,715 +numpy/array_api/_manipulation_functions.py,sha256=qCoW5B5FXcFOWKPU9D9MXHdMeXIuzvnHUUvprNlwfjc,3317 +numpy/array_api/_searching_functions.py,sha256=mGZiqheYXGWiDK9rqXFiDKX0_B0mJ1OjdA-9FC2o5lA,1715 +numpy/array_api/_set_functions.py,sha256=ULpfK1zznW9joX1DXSiP0R3ahcDB_po7mZlpsRqi7Fs,2948 +numpy/array_api/_sorting_functions.py,sha256=7pszlxNN7-DNqEZlonGLFQrlXPP7evVA8jN31NShg00,2031 +numpy/array_api/_statistical_functions.py,sha256=HspfYteZWSa3InMs10KZz-sk3ZuW6teX6fNdo829T84,3584 +numpy/array_api/_typing.py,sha256=uKidRp6nYxgHnEPaqXXZsDDZ6tw1LshpbwLvy-09eeM,1347 +numpy/array_api/_utility_functions.py,sha256=HwycylbPAgRVz4nZvjvwqN3mQnJbqKA-NRMaAvIP-CE,824 +numpy/array_api/linalg.py,sha256=QPpG2tG1pZgzjrtTjjOu2GDu3cI6UpSsLrsG_o1jXYk,18411 +numpy/array_api/setup.py,sha256=Wx6qD7GU_APiqKolYPO0OHv4eHGYrjPZmDAgjWhOEhM,341 +numpy/array_api/tests/__init__.py,sha256=t_2GZ3lKcsu4ec4GMKPUDYaeMUJyDquBlQAcPgj7kFE,282 +numpy/array_api/tests/__pycache__/__init__.cpython-312.pyc,, +numpy/array_api/tests/__pycache__/test_array_object.cpython-312.pyc,, +numpy/array_api/tests/__pycache__/test_creation_functions.cpython-312.pyc,, +numpy/array_api/tests/__pycache__/test_data_type_functions.cpython-312.pyc,, +numpy/array_api/tests/__pycache__/test_elementwise_functions.cpython-312.pyc,, +numpy/array_api/tests/__pycache__/test_indexing_functions.cpython-312.pyc,, +numpy/array_api/tests/__pycache__/test_manipulation_functions.cpython-312.pyc,, +numpy/array_api/tests/__pycache__/test_set_functions.cpython-312.pyc,, +numpy/array_api/tests/__pycache__/test_sorting_functions.cpython-312.pyc,, +numpy/array_api/tests/__pycache__/test_validation.cpython-312.pyc,, +numpy/array_api/tests/test_array_object.py,sha256=FQoAxP4CLDiv6iih8KKUDSLuYM6dtnDcB1f0pMHw4-M,17035 +numpy/array_api/tests/test_creation_functions.py,sha256=s3A1COWmXIAJdhzd8v7VtL-jbiSspskTqwYy0BTpmpw,5023 +numpy/array_api/tests/test_data_type_functions.py,sha256=qc8ktRlVXWC3PKhxPVWI_UF9f1zZtpmzHjdCtf3e16E,1018 +numpy/array_api/tests/test_elementwise_functions.py,sha256=CTj4LLwtusI51HkpzD0JPohP1ffNxogAVFz8WLuWFzM,3800 +numpy/array_api/tests/test_indexing_functions.py,sha256=AbuBGyEufEAf24b7fy8JQhdJtGPdP9XEIxPTJAfAFFo,627 +numpy/array_api/tests/test_manipulation_functions.py,sha256=wce25dSJjubrGhFxmiatzR_IpmNYp9ICJ9PZBBnZTOQ,1087 +numpy/array_api/tests/test_set_functions.py,sha256=D016G7v3ko49bND5sVERP8IqQXZiwr-2yrKbBPJ-oqg,546 +numpy/array_api/tests/test_sorting_functions.py,sha256=INPiYnuGBcsmWtYqdTTX3ENHmM4iUx4zs9KdwDaSmdA,602 +numpy/array_api/tests/test_validation.py,sha256=QUG9yWC3QhkPxNhbQeakwBbl-0Rr0iTuZ41_0sfVIGU,676 +numpy/compat/__init__.py,sha256=iAHrmsZWzouOMSyD9bdSE0APWMlRpqW92MQgF8y6x3E,448 +numpy/compat/__pycache__/__init__.cpython-312.pyc,, +numpy/compat/__pycache__/py3k.cpython-312.pyc,, +numpy/compat/__pycache__/setup.cpython-312.pyc,, +numpy/compat/py3k.py,sha256=Je74CVk_7qI_qX7pLbYcuQJsxlMq1poGIfRIrH99kZQ,3833 +numpy/compat/setup.py,sha256=36X1kF0C_NVROXfJ7w3SQeBm5AIDBuJbM5qT7cvSDgU,335 +numpy/compat/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +numpy/compat/tests/__pycache__/__init__.cpython-312.pyc,, +numpy/compat/tests/__pycache__/test_compat.cpython-312.pyc,, +numpy/compat/tests/test_compat.py,sha256=YqV67pSN8nXPbXaEdjhmyaoVetNyFupVv57OMEgCwKA,579 +numpy/conftest.py,sha256=HZyWo_wJyrbgnyXxI8t05WOg_IrzNAMnEV7O8koHous,4623 +numpy/core/__init__.py,sha256=CNsO-Ab4ywM2Wz3AbqWOH3ig1q5Bno9PsUMrCv-HNS4,5780 +numpy/core/__init__.pyi,sha256=xtd9OFYza-ZG3jyEJrlzRPT-SkVoB_qYmVCe6FxRks0,126 +numpy/core/__pycache__/__init__.cpython-312.pyc,, +numpy/core/__pycache__/_add_newdocs.cpython-312.pyc,, +numpy/core/__pycache__/_add_newdocs_scalars.cpython-312.pyc,, +numpy/core/__pycache__/_asarray.cpython-312.pyc,, +numpy/core/__pycache__/_dtype.cpython-312.pyc,, +numpy/core/__pycache__/_dtype_ctypes.cpython-312.pyc,, +numpy/core/__pycache__/_exceptions.cpython-312.pyc,, +numpy/core/__pycache__/_internal.cpython-312.pyc,, +numpy/core/__pycache__/_machar.cpython-312.pyc,, +numpy/core/__pycache__/_methods.cpython-312.pyc,, +numpy/core/__pycache__/_string_helpers.cpython-312.pyc,, +numpy/core/__pycache__/_type_aliases.cpython-312.pyc,, +numpy/core/__pycache__/_ufunc_config.cpython-312.pyc,, +numpy/core/__pycache__/arrayprint.cpython-312.pyc,, +numpy/core/__pycache__/cversions.cpython-312.pyc,, +numpy/core/__pycache__/defchararray.cpython-312.pyc,, +numpy/core/__pycache__/einsumfunc.cpython-312.pyc,, +numpy/core/__pycache__/fromnumeric.cpython-312.pyc,, +numpy/core/__pycache__/function_base.cpython-312.pyc,, +numpy/core/__pycache__/getlimits.cpython-312.pyc,, +numpy/core/__pycache__/memmap.cpython-312.pyc,, +numpy/core/__pycache__/multiarray.cpython-312.pyc,, +numpy/core/__pycache__/numeric.cpython-312.pyc,, +numpy/core/__pycache__/numerictypes.cpython-312.pyc,, +numpy/core/__pycache__/overrides.cpython-312.pyc,, +numpy/core/__pycache__/records.cpython-312.pyc,, +numpy/core/__pycache__/shape_base.cpython-312.pyc,, +numpy/core/__pycache__/umath.cpython-312.pyc,, +numpy/core/__pycache__/umath_tests.cpython-312.pyc,, +numpy/core/_add_newdocs.py,sha256=39JFaeDPN2OQlSwfpY6_Jq9fO5vML8ZMF8J4ZTx_nrs,208972 +numpy/core/_add_newdocs_scalars.py,sha256=PF9v8POcSNH6ELYltkx9e07DWgMmft6NJy9zER3Jk44,12106 +numpy/core/_asarray.py,sha256=P2ddlZAsg1iGleRRfoQv_aKs2N7AGwpo5K4ZQv4Ujlk,3884 +numpy/core/_asarray.pyi,sha256=gNNxUVhToNU_F1QpgeEvUYddpUFN-AKP0QWa4gqcTGw,1086 +numpy/core/_dtype.py,sha256=SihUz41pHRB3Q2LiYYkug6LgMBKh6VV89MOpLxnXQdo,10606 +numpy/core/_dtype_ctypes.py,sha256=Vug4i7xKhznK2tdIjmn4ebclClpaCJwSZUlvEoYl0Eg,3673 +numpy/core/_exceptions.py,sha256=dZWKqfdLRvJvbAEG_fof_8ikEKxjakADMty1kLC_l_M,5379 +numpy/core/_internal.py,sha256=f9kNDuT-FGxF1EtVOVIxXWnH9gM9n-J5V2zwHMv4HEk,28348 +numpy/core/_internal.pyi,sha256=_mCTOX6Su8D4R9fV4HNeohPJx7515B-WOlv4uq6mry8,1032 +numpy/core/_machar.py,sha256=G3a3TXu8VDW_1EMxKKLnGMbvUShEIUEve3ealBlJJ3E,11565 +numpy/core/_methods.py,sha256=m31p0WjcFUGckbJiHnCpSaIQGqv-Lq5niIYkdd33YMo,8613 +numpy/core/_multiarray_tests.cpython-312-x86_64-linux-gnu.so,sha256=Cyy7dBn_wvcSmHqrr1GKOx2d6EBgk_edyx1xKjSrYFc,175912 +numpy/core/_multiarray_umath.cpython-312-x86_64-linux-gnu.so,sha256=amUIEKhzXL25iPdHKZc3QKM3ZF3RWF_vaW5z4tvGW-s,7463681 +numpy/core/_operand_flag_tests.cpython-312-x86_64-linux-gnu.so,sha256=VPbGfwOkzwWoNNVSh3jahuBTI8LrKbN_dCaMcOtDfQE,16856 +numpy/core/_rational_tests.cpython-312-x86_64-linux-gnu.so,sha256=0JmPpR0Ej5eZ4vrHN_6fvrKVCeUVuQam83AxViSkN2k,59776 +numpy/core/_simd.cpython-312-x86_64-linux-gnu.so,sha256=lAK8a8uKjaYoFqMQZBWnVvjeUm-KDsnZzyH_RThl9do,3535232 +numpy/core/_string_helpers.py,sha256=-fQM8z5s8_yX440PmgNEH3SUjEoXMPpPSysZwWZNbuo,2852 +numpy/core/_struct_ufunc_tests.cpython-312-x86_64-linux-gnu.so,sha256=PB6RqEbim2Ezi96GVTzyqi9IuqNcVGCKcgPxwHBVCAM,16960 +numpy/core/_type_aliases.py,sha256=qV6AZlsUWHMWTydmZya73xuBkKXiUKq_WXLj7q2CbZ0,7534 +numpy/core/_type_aliases.pyi,sha256=lguMSqMwvqAFHuRtm8YZSdKbikVz985BdKo_lo7GQCg,404 +numpy/core/_ufunc_config.py,sha256=-Twpe8dnd45ccXH-w-B9nvU8yCOd1E0e3Wpsts3g_bQ,13944 +numpy/core/_ufunc_config.pyi,sha256=-615enOVQMBhVx7Pln7DY_s4H6JjSgSnBy89YkpvuLg,1066 +numpy/core/_umath_tests.cpython-312-x86_64-linux-gnu.so,sha256=kT7z3gJc2t_GgamgqAf3MNRWeVo8KrSWPZVh3mLs_t8,42272 +numpy/core/arrayprint.py,sha256=ySZj4TZFFVCa5yhMmJKFYQYhuQTabZTRBb1YoiCD-ac,63608 +numpy/core/arrayprint.pyi,sha256=21pOWjTSfJOBaKgOOPzRox1ERb3c9ydufqL0b11_P_Q,4428 +numpy/core/cversions.py,sha256=H_iNIpx9-hY1cQNxqjT2d_5SXZhJbMo_caq4_q6LB7I,347 +numpy/core/defchararray.py,sha256=G1LExk-dMeVTYRhtYgcCZEsHk5tkawk7giXcK4Q5KVM,73617 +numpy/core/defchararray.pyi,sha256=ib3aWFcM7F4KooU57mWUNi4GlosNjdfgrLKBVSIKDvU,9216 +numpy/core/einsumfunc.py,sha256=TrL6t79F0H0AQH0y5Cj7Tq0_pzk4fVFi-4q4jJmujYQ,51868 +numpy/core/einsumfunc.pyi,sha256=IJZNdHHG_soig8XvCbXZl43gMr3MMKl9dckTYWecqLs,4860 +numpy/core/fromnumeric.py,sha256=YMtxOBg51VMem39AHXFs-4_vOb1p48ei7njXdYTRJ_Q,128821 +numpy/core/fromnumeric.pyi,sha256=KATMFeFxUJ8YNRaC-jd_dTOt3opz2ng6lHgke5u5COk,23726 +numpy/core/function_base.py,sha256=tHg1qSHTz1eO_wHXNFRt3Q40uqVtPT2eyQdrWbIi4wQ,19836 +numpy/core/function_base.pyi,sha256=3ZYad3cdaGwNEyP8VwK97IYMqk2PDoVjpjQzhIYHjk0,4725 +numpy/core/getlimits.py,sha256=AopcTZDCUXMPcEKIZE1botc3mEhmLb2p1_ejlq1CLqY,25865 +numpy/core/getlimits.pyi,sha256=qeIXUEtognTHr_T-tv-VcZI7n8Z2VzAyIpIgKXzsLkc,82 +numpy/core/include/numpy/__multiarray_api.c,sha256=nPRzTez_Wy3YXy3zZNJNPMspAzxbLOdohqhXwouwMLM,12116 +numpy/core/include/numpy/__multiarray_api.h,sha256=ZM--FKMhIaSQS39cPW0hj5dx8ngNMmbcy6SbgXZBd8U,61450 +numpy/core/include/numpy/__ufunc_api.c,sha256=670Gcz-vhkF4taBDmktCpFRBrZ9CHJnPRx7ag7Z6HsI,1714 +numpy/core/include/numpy/__ufunc_api.h,sha256=0MBOl7dgO3ldqdDi-SdciEOuqGv1UNsmk7mp7tEy4AY,12456 +numpy/core/include/numpy/_dtype_api.h,sha256=4veCexGvx9KNWMIUuEUAVOfcsei9GqugohDY5ud16pA,16697 +numpy/core/include/numpy/_neighborhood_iterator_imp.h,sha256=s-Hw_l5WRwKtYvsiIghF0bg-mA_CgWnzFFOYVFJ-q4k,1857 +numpy/core/include/numpy/_numpyconfig.h,sha256=o0fV_jb-wgVtRxnVIWvUttiZafyrWYFm2ab9Uixz1Cw,855 +numpy/core/include/numpy/arrayobject.h,sha256=-BlWQ7kfVbzCqzHn0qaeMe0_08AbwliuG98XWG57lT8,282 +numpy/core/include/numpy/arrayscalars.h,sha256=C3vDRndZTZRbppiDyV5jp8sV3dRKsrwBIZcNlh9gSTA,3944 +numpy/core/include/numpy/experimental_dtype_api.h,sha256=tlehD5r_pYhHbGzIrUea6vtOgf6IQ8Txblnhx7455h8,15532 +numpy/core/include/numpy/halffloat.h,sha256=TRZfXgipa-dFppX2uNgkrjrPli-1BfJtadWjAembJ4s,1959 +numpy/core/include/numpy/ndarrayobject.h,sha256=PhY4NjRZDoU5Zbc8MW0swPEm81hwgWZ63gAU93bLVVI,10183 +numpy/core/include/numpy/ndarraytypes.h,sha256=EjWXv-J8C5JET4AlIbJRdctycL7-dyJZcnoWgnlCPc8,68009 +numpy/core/include/numpy/noprefix.h,sha256=d83l1QpCCVqMV2k29NMkL3Ld1qNjiC6hzOPWZAivEjQ,6830 +numpy/core/include/numpy/npy_1_7_deprecated_api.h,sha256=y0MJ8Qw7Bkt4H_4VxIzHzpkw5JqAdj5ECgtn08fZFrI,4327 +numpy/core/include/numpy/npy_3kcompat.h,sha256=SvN9yRA3i02O4JFMXxZz0Uq_vJ5ZpvC-pC2sfF56A5I,15883 +numpy/core/include/numpy/npy_common.h,sha256=apWBsCJeP8P5T0exgzhFcGohbASsUF8vtFdS2jc1VfU,37746 +numpy/core/include/numpy/npy_cpu.h,sha256=pcVRtj-Y6120C5kWB1VAiAjZoxkTPDEg0gGm5IAt3jM,4629 +numpy/core/include/numpy/npy_endian.h,sha256=we7X9fPeWzNpo_YTh09MPGDwdE0Rw_WDM4c9y4nBj5I,2786 +numpy/core/include/numpy/npy_interrupt.h,sha256=DQZIxi6FycLXD8drdHn2SSmLoRhIpo6osvPv13vowUA,1948 +numpy/core/include/numpy/npy_math.h,sha256=SbKRoc7O3gVuDl7HOZjk424O049I0zn-7i9GwBwNmmk,18945 +numpy/core/include/numpy/npy_no_deprecated_api.h,sha256=0yZrJcQEJ6MCHJInQk5TP9_qZ4t7EfBuoLOJ34IlJd4,678 +numpy/core/include/numpy/npy_os.h,sha256=hlQsg_7-RkvS3s8OM8KXy99xxyJbCm-W1AYVcdnO1cw,1256 +numpy/core/include/numpy/numpyconfig.h,sha256=Nr59kE3cXmen6y0UymIBaU7F1BSIuPwgKZ4gdV5Q5JU,5308 +numpy/core/include/numpy/old_defines.h,sha256=xuYQDDlMywu0Zsqm57hkgGwLsOFx6IvxzN2eiNF-gJY,6405 +numpy/core/include/numpy/random/LICENSE.txt,sha256=-8U59H0M-DvGE3gID7hz1cFGMBJsrL_nVANcOSbapew,1018 +numpy/core/include/numpy/random/bitgen.h,sha256=49AwKOR552r-NkhuSOF1usb_URiMSRMvD22JF5pKIng,488 +numpy/core/include/numpy/random/distributions.h,sha256=W5tOyETd0m1W0GdaZ5dJP8fKlBtsTpG23V2Zlmrlqpg,9861 +numpy/core/include/numpy/random/libdivide.h,sha256=ew9MNhPQd1LsCZiWiFmj9IZ7yOnA3HKOXffDeR9X1jw,80138 +numpy/core/include/numpy/ufuncobject.h,sha256=Xmnny_ulZo9VwxkfkXF-1HCTKDavIp9PV_H7XWhi0Z8,12070 +numpy/core/include/numpy/utils.h,sha256=wMNomSH3Dfj0q78PrjLVtFtN-FPo7UJ4o0ifCUO-6Es,1185 +numpy/core/lib/libnpymath.a,sha256=mb8EluEp8SLpEeCTQJ0VshL-CqeZfWxSbS5ItM-9POc,93960 +numpy/core/lib/npy-pkg-config/mlib.ini,sha256=_LsWV1eStNqwhdiYPa2538GL46dnfVwT4MrI1zbsoFw,147 +numpy/core/lib/npy-pkg-config/npymath.ini,sha256=kamUNrYKAmXqQa8BcNv7D5sLqHh6bnChM0_5rZCsTfY,360 +numpy/core/memmap.py,sha256=yWBJLeVClHsD8BYusnf9bdqypOMPrj3_zoO_lQ2zVMc,11771 +numpy/core/memmap.pyi,sha256=sxIQ7T5hPLG-RBNndAc8JPvrsKEX1amBSH2HGg48Obo,55 +numpy/core/multiarray.py,sha256=zXaWf_DSkFEWjUQqVRCGeevwsI6kjQ3x6_MUwA1Y8fk,56097 +numpy/core/multiarray.pyi,sha256=_0X4W90U5ZiKt2n-9OscK-pcQyV6oGK-8jwGy5k1qxA,24768 +numpy/core/numeric.py,sha256=DgajaCDXiiQR-zuW_rrx_QhApSsa5k5FONK3Uk9mfTs,77014 +numpy/core/numeric.pyi,sha256=oVQkI4ABayFl_ZzCiGH4DxfYASL-3aETi-3B93THnEQ,14315 +numpy/core/numerictypes.py,sha256=qIf9v1OpNjjVQzXnKpD-3V01y5Bj9huw5F-U5Wa4glc,18098 +numpy/core/numerictypes.pyi,sha256=dEqtq9MLrGaqqeAF1sdXBgnEwDWOzlK02A6MTg1PS5g,3267 +numpy/core/overrides.py,sha256=YUZFS8RCBvOJ27sH-jDRcyMjOCn9VigMyuQY4J21JBI,7093 +numpy/core/records.py,sha256=4mpIjUp2XtZxY5cD2S8mgfn8GCzQGGrrkqLBqAJwM-Q,37533 +numpy/core/records.pyi,sha256=uYwE6cAoGKgN6U4ryfGZx_3m-3sY006jytjWLrDRRy0,5692 +numpy/core/shape_base.py,sha256=RPMKxA7_FCAgg_CruExl0LehnczSTFaxA6hrcfrUzns,29743 +numpy/core/shape_base.pyi,sha256=Ilb4joJmbjkIZLzKww7NJeaxg2FP3AfFib3HtfOsrC0,2774 +numpy/core/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +numpy/core/tests/__pycache__/__init__.cpython-312.pyc,, +numpy/core/tests/__pycache__/_locales.cpython-312.pyc,, +numpy/core/tests/__pycache__/test__exceptions.cpython-312.pyc,, +numpy/core/tests/__pycache__/test_abc.cpython-312.pyc,, +numpy/core/tests/__pycache__/test_api.cpython-312.pyc,, +numpy/core/tests/__pycache__/test_argparse.cpython-312.pyc,, +numpy/core/tests/__pycache__/test_array_coercion.cpython-312.pyc,, +numpy/core/tests/__pycache__/test_array_interface.cpython-312.pyc,, +numpy/core/tests/__pycache__/test_arraymethod.cpython-312.pyc,, +numpy/core/tests/__pycache__/test_arrayprint.cpython-312.pyc,, +numpy/core/tests/__pycache__/test_casting_floatingpoint_errors.cpython-312.pyc,, +numpy/core/tests/__pycache__/test_casting_unittests.cpython-312.pyc,, +numpy/core/tests/__pycache__/test_conversion_utils.cpython-312.pyc,, +numpy/core/tests/__pycache__/test_cpu_dispatcher.cpython-312.pyc,, +numpy/core/tests/__pycache__/test_cpu_features.cpython-312.pyc,, +numpy/core/tests/__pycache__/test_custom_dtypes.cpython-312.pyc,, +numpy/core/tests/__pycache__/test_cython.cpython-312.pyc,, +numpy/core/tests/__pycache__/test_datetime.cpython-312.pyc,, +numpy/core/tests/__pycache__/test_defchararray.cpython-312.pyc,, +numpy/core/tests/__pycache__/test_deprecations.cpython-312.pyc,, +numpy/core/tests/__pycache__/test_dlpack.cpython-312.pyc,, +numpy/core/tests/__pycache__/test_dtype.cpython-312.pyc,, +numpy/core/tests/__pycache__/test_einsum.cpython-312.pyc,, +numpy/core/tests/__pycache__/test_errstate.cpython-312.pyc,, +numpy/core/tests/__pycache__/test_extint128.cpython-312.pyc,, +numpy/core/tests/__pycache__/test_function_base.cpython-312.pyc,, +numpy/core/tests/__pycache__/test_getlimits.cpython-312.pyc,, +numpy/core/tests/__pycache__/test_half.cpython-312.pyc,, +numpy/core/tests/__pycache__/test_hashtable.cpython-312.pyc,, +numpy/core/tests/__pycache__/test_indexerrors.cpython-312.pyc,, +numpy/core/tests/__pycache__/test_indexing.cpython-312.pyc,, +numpy/core/tests/__pycache__/test_item_selection.cpython-312.pyc,, +numpy/core/tests/__pycache__/test_limited_api.cpython-312.pyc,, +numpy/core/tests/__pycache__/test_longdouble.cpython-312.pyc,, +numpy/core/tests/__pycache__/test_machar.cpython-312.pyc,, +numpy/core/tests/__pycache__/test_mem_overlap.cpython-312.pyc,, +numpy/core/tests/__pycache__/test_mem_policy.cpython-312.pyc,, +numpy/core/tests/__pycache__/test_memmap.cpython-312.pyc,, +numpy/core/tests/__pycache__/test_multiarray.cpython-312.pyc,, +numpy/core/tests/__pycache__/test_nditer.cpython-312.pyc,, +numpy/core/tests/__pycache__/test_nep50_promotions.cpython-312.pyc,, +numpy/core/tests/__pycache__/test_numeric.cpython-312.pyc,, +numpy/core/tests/__pycache__/test_numerictypes.cpython-312.pyc,, +numpy/core/tests/__pycache__/test_numpy_2_0_compat.cpython-312.pyc,, +numpy/core/tests/__pycache__/test_overrides.cpython-312.pyc,, +numpy/core/tests/__pycache__/test_print.cpython-312.pyc,, +numpy/core/tests/__pycache__/test_protocols.cpython-312.pyc,, +numpy/core/tests/__pycache__/test_records.cpython-312.pyc,, +numpy/core/tests/__pycache__/test_regression.cpython-312.pyc,, +numpy/core/tests/__pycache__/test_scalar_ctors.cpython-312.pyc,, +numpy/core/tests/__pycache__/test_scalar_methods.cpython-312.pyc,, +numpy/core/tests/__pycache__/test_scalarbuffer.cpython-312.pyc,, +numpy/core/tests/__pycache__/test_scalarinherit.cpython-312.pyc,, +numpy/core/tests/__pycache__/test_scalarmath.cpython-312.pyc,, +numpy/core/tests/__pycache__/test_scalarprint.cpython-312.pyc,, +numpy/core/tests/__pycache__/test_shape_base.cpython-312.pyc,, +numpy/core/tests/__pycache__/test_simd.cpython-312.pyc,, +numpy/core/tests/__pycache__/test_simd_module.cpython-312.pyc,, +numpy/core/tests/__pycache__/test_strings.cpython-312.pyc,, +numpy/core/tests/__pycache__/test_ufunc.cpython-312.pyc,, +numpy/core/tests/__pycache__/test_umath.cpython-312.pyc,, +numpy/core/tests/__pycache__/test_umath_accuracy.cpython-312.pyc,, +numpy/core/tests/__pycache__/test_umath_complex.cpython-312.pyc,, +numpy/core/tests/__pycache__/test_unicode.cpython-312.pyc,, +numpy/core/tests/_locales.py,sha256=S4x5soqF0oxpBYOE8J9Iky72O9J25IiZ8349m93pWC4,2206 +numpy/core/tests/data/astype_copy.pkl,sha256=lWSzCcvzRB_wpuRGj92spGIw-rNPFcd9hwJaRVvfWdk,716 +numpy/core/tests/data/generate_umath_validation_data.cpp,sha256=fyhQPNhIX9hzjeXujn6mhi1MVc133zELSV_hlSQ7BQU,5842 +numpy/core/tests/data/numpy_2_0_array.pkl,sha256=Vh02tdyCypa8Nb4QzdVhnDAiXEO2WQrcwcvOdDDFF5w,718 +numpy/core/tests/data/recarray_from_file.fits,sha256=NA0kliz31FlLnYxv3ppzeruONqNYkuEvts5wzXEeIc4,8640 +numpy/core/tests/data/umath-validation-set-README.txt,sha256=pxWwOaGGahaRd-AlAidDfocLyrAiDp0whf5hC7hYwqM,967 +numpy/core/tests/data/umath-validation-set-arccos.csv,sha256=W_aL99bjzVjlVyd5omfDUORag8jHzx6uctedPVZgOHQ,61365 +numpy/core/tests/data/umath-validation-set-arccosh.csv,sha256=Uko_d0kDXr1YlN-6Ii-fQQxUvbXAhRfC7Un4gJ23GJk,61365 +numpy/core/tests/data/umath-validation-set-arcsin.csv,sha256=15Aenze4WD2a2dF2aOBXpv9B7u3wwAeUVJdEm4TjOkQ,61339 +numpy/core/tests/data/umath-validation-set-arcsinh.csv,sha256=uDwx4PStpfV21IaPF8pmzQpul6i72g7zDwlfcynWaVQ,60289 +numpy/core/tests/data/umath-validation-set-arctan.csv,sha256=mw5tYze_BMs6ugGEZfg5mcXoInGYdn7fvSCYSUi9Bqw,60305 +numpy/core/tests/data/umath-validation-set-arctanh.csv,sha256=95l4Uu5RmZajljabfqlv5U34RVrifCMhhkop6iLeNBo,61339 +numpy/core/tests/data/umath-validation-set-cbrt.csv,sha256=v855MTZih-fZp_GuEDst2qaIsxU4a7vlAbeIJy2xKpc,60846 +numpy/core/tests/data/umath-validation-set-cos.csv,sha256=0PNnDqKkokZ7ERVDgbes8KNZc-ISJrZUlVZc5LkW18E,59122 +numpy/core/tests/data/umath-validation-set-cosh.csv,sha256=FGCNeUSUTAeASsb_j18iRSsCxXLxmzF-_C7tq1elVrQ,60869 +numpy/core/tests/data/umath-validation-set-exp.csv,sha256=BKg1_cyrKD2GXYMX_EB0DnXua8DI2O1KWODXf_BRhrk,17491 +numpy/core/tests/data/umath-validation-set-exp2.csv,sha256=f1b05MRXPOXihC9M-yi52udKBzVXalhbTuIcqoDAk-g,58624 +numpy/core/tests/data/umath-validation-set-expm1.csv,sha256=_ghc1xiUECNsBGrKCFUAy2lvu01_lkpeYJN0zDtCYWk,60299 +numpy/core/tests/data/umath-validation-set-log.csv,sha256=z9ej1ykKUoMRqYMUIJENWXbYi_A_x_RKs7K_GuXZJus,11692 +numpy/core/tests/data/umath-validation-set-log10.csv,sha256=RJgpruL16FVPgUT3-3xW4eppS_tn6o5yEW79KnITn48,68922 +numpy/core/tests/data/umath-validation-set-log1p.csv,sha256=IZZI-hi55HGCOvBat3vSBVha_8Nt-5alf2fqz6QeTG0,60303 +numpy/core/tests/data/umath-validation-set-log2.csv,sha256=HL2rOCsrEi378rNrbsXHPqlWlEGkXQq8R4e63YeTksU,68917 +numpy/core/tests/data/umath-validation-set-sin.csv,sha256=8PUjnQ_YfmxFb42XJrvpvmkeSpEOlEXSmNvIK4VgfAM,58611 +numpy/core/tests/data/umath-validation-set-sinh.csv,sha256=CYiibE8aX7MQnBatl__5k_PWc_9vHUifwS-sFZzzKk0,60293 +numpy/core/tests/data/umath-validation-set-tan.csv,sha256=Oq7gxMvblRVBrQ23kMxc8iT0bHnCWKg9EE4ZqzbJbOA,60299 +numpy/core/tests/data/umath-validation-set-tanh.csv,sha256=iolZF_MOyWRgYSa-SsD4df5mnyFK18zrICI740SWoTc,60299 +numpy/core/tests/examples/cython/__pycache__/setup.cpython-312.pyc,, +numpy/core/tests/examples/cython/checks.pyx,sha256=rKAhPSGHJ9oPK9Q_85YoUQyRTftEP1jcYOR5lSPB6oQ,662 +numpy/core/tests/examples/cython/meson.build,sha256=Qk4Q6OkpZ0xsLUkcGQVVrYkzb0ozoyL6YlSZ8_5tH1I,1088 +numpy/core/tests/examples/cython/setup.py,sha256=aAR-TvQabUabnCzuB6UdWdmRXaaPfIG7MzTIfMF-0tk,496 +numpy/core/tests/examples/limited_api/__pycache__/setup.cpython-312.pyc,, +numpy/core/tests/examples/limited_api/limited_api.c,sha256=mncE8TjjXmYpkwli433G0jB2zGQO_5NqWmGKdzRJZug,344 +numpy/core/tests/examples/limited_api/setup.py,sha256=p2w7F1ardi_GRXSrnNIR8W1oeH_pgmw_1P2wS0A2I6M,435 +numpy/core/tests/test__exceptions.py,sha256=QqxQSLXboPXEVwHz-TyE2JeIl_TC-rPugzfo25nbcns,2846 +numpy/core/tests/test_abc.py,sha256=FfgYA_HjYAi8XWGK_oOh6Zw86chB_KG_XoW_7ZlFp4c,2220 +numpy/core/tests/test_api.py,sha256=UMc7SvczAQ5ngHxE-NoXVvNpVzYRrn8oMwFNta1yMS0,22995 +numpy/core/tests/test_argparse.py,sha256=C0zBbwQ9xzzymXe_hHpWnnWQPwOi2ZdQB78gBAgJHvU,1969 +numpy/core/tests/test_array_coercion.py,sha256=zY4Pjlt4QZ0w71WxWGLHcrPnnhEF51yXYVLg5HMIy5c,34379 +numpy/core/tests/test_array_interface.py,sha256=8tGgj1Nzi76H_WF5GULkxqWL7Yu_Xf0lvTJZOwOBKsI,7774 +numpy/core/tests/test_arraymethod.py,sha256=VpjDYTmoMDTZcY7CsGzinBh0R_OICuwOykWCbmCRQZU,3244 +numpy/core/tests/test_arrayprint.py,sha256=cKaIoD9ZvsjJH0PHwZyOxmcRcBt1kN1WfFneqVqs0b8,40462 +numpy/core/tests/test_casting_floatingpoint_errors.py,sha256=W3Fgk0oKtXFv684fEZ7POwj6DHTYK0Jj_oGRLZ8UdyA,5063 +numpy/core/tests/test_casting_unittests.py,sha256=9-vkR0oXczQz8ED8DxGVPmalC8IZXe2jKgOCMGr8hIg,34298 +numpy/core/tests/test_conversion_utils.py,sha256=jNhbNNI-T8qtQnsIMEax7KFN30kjh0ICntLMwTyxJ5Q,6559 +numpy/core/tests/test_cpu_dispatcher.py,sha256=v_SlhUpENuoe7QYXizzYITLGXa7WfZ7jqcqmbSBg7JU,1542 +numpy/core/tests/test_cpu_features.py,sha256=mieGx7dxXFiyTYatbcCCjIjR67Un2hVcbJx4GEf2yFo,14892 +numpy/core/tests/test_custom_dtypes.py,sha256=JogRmttDLwfQ3PTbewEnGLKco9zV2Nu3yIfrMeCsx_I,9401 +numpy/core/tests/test_cython.py,sha256=t5-h4XSIFNLyw_9BIAQDYl8_80t_pH0SCfEa1Vf_3aI,3755 +numpy/core/tests/test_datetime.py,sha256=2vAGbrCQmsrWNXCVXOMZqUGZn2c-cQT-eZ1wTprYbcM,116211 +numpy/core/tests/test_defchararray.py,sha256=F88HUkByEP4H6cJ_ITvIe0a_T1BH2JOdRysMCu1XIn0,24997 +numpy/core/tests/test_deprecations.py,sha256=w2lhHb-W8hh7RoE_0Ftg8thpG86jvbFAJgior22DY2Q,31076 +numpy/core/tests/test_dlpack.py,sha256=cDlwFmTombb2rDeB8RHEAJ4eVMUiDbw8Oz5Jo1NQwk0,3522 +numpy/core/tests/test_dtype.py,sha256=J09pJF59v7UO6iNuJFISKP2DLPgdkQ_df5OAMDRLikU,75702 +numpy/core/tests/test_einsum.py,sha256=QzQAPIC-IjTV3Dxz97hBnvLBCmF8kpsBTBckThhgRjQ,53712 +numpy/core/tests/test_errstate.py,sha256=U3GT9I058jkF725mx4GdWUr9RoceCkGDV7Go79VA4wY,2219 +numpy/core/tests/test_extint128.py,sha256=gCZfAwPOb-F1TLsEEeDI0amQYwHk-60-OXi0ccZrrZ8,5643 +numpy/core/tests/test_function_base.py,sha256=Ibs6-WXZE5hsRx4VCnX-cZOWYKU-5PFXjouwAQzgnqQ,15595 +numpy/core/tests/test_getlimits.py,sha256=apdxr0zKkxaVHIUpLrqAvO39q54JKN14sV4xSbK2Ifs,6718 +numpy/core/tests/test_half.py,sha256=VYPyap9GYOWZuphsfFofcIRl-oa5Ufrtv83OTp6azdU,24593 +numpy/core/tests/test_hashtable.py,sha256=ZV8HL8NkDnoQZfnje7BP0fyIp4fSFqjKsQc40PaTggc,1011 +numpy/core/tests/test_indexerrors.py,sha256=kN9xLl6FVTzmI7fumn_cuZ3k0omXnTetgtCnPY44cvw,5130 +numpy/core/tests/test_indexing.py,sha256=x0ojWuhOwWD5MZuiJ9Ncim3CgkwI-GldWxrSCmjmFJM,54314 +numpy/core/tests/test_item_selection.py,sha256=kI30kiX8mIrZYPn0jw3lGGw1ruZF4PpE9zw-aai9EPA,6458 +numpy/core/tests/test_limited_api.py,sha256=5yO0nGmCKZ9b3S66QP7vY-HIgAoyOtHZmp8mvzKuOHI,1172 +numpy/core/tests/test_longdouble.py,sha256=jO8YMm_Hsz-XPKbmv6iMcOdHgTlIFkKTwAtxpy3Q1pE,13905 +numpy/core/tests/test_machar.py,sha256=_5_TDUVtAJvJI5jBfEFKpCZtAfKCsCFt7tXlWSkWzzc,1067 +numpy/core/tests/test_mem_overlap.py,sha256=QJ0unWD_LOoAGAo4ra0IvYenj56IYUtiz1fEJEmTY9Q,29086 +numpy/core/tests/test_mem_policy.py,sha256=CXa10FQw2Qj6MqJuaC8Fm4slsoipKFjCIpYF6c5IIAU,16801 +numpy/core/tests/test_memmap.py,sha256=tZ5lJs_4ZFsJmg392ZQ33fX0m8tdfZ8ZtY9Lq41LNtk,7477 +numpy/core/tests/test_multiarray.py,sha256=GPv4IJR9dijNG-icUsQsX2tBD2RdP3EhUehY4cxvVQU,380106 +numpy/core/tests/test_nditer.py,sha256=nVQ00aNxPHqf4ZcFs3e9AVDK64TCqlO0TzfocTAACZQ,130818 +numpy/core/tests/test_nep50_promotions.py,sha256=2TwtFvj1LBpYTtdR6NFe1RAAGXIJltLqwpA1vhQCVY4,8840 +numpy/core/tests/test_numeric.py,sha256=ZGNW5NKgShEjZC_TcPOtTuRaTM_GbuM21u82D205UPs,137294 +numpy/core/tests/test_numerictypes.py,sha256=f_xMjZJnyDwlc6XCrd71b6x1_6dAWOv-kZ3-NEq37hU,21687 +numpy/core/tests/test_numpy_2_0_compat.py,sha256=kVCTAXska7Xi5w_TYduWhid0nlCqI6Nvmt-gDnYsuKI,1630 +numpy/core/tests/test_overrides.py,sha256=t0gOZOzu7pevE58HA-npFYJqnInHR-LLBklnzKJWHqo,26080 +numpy/core/tests/test_print.py,sha256=ErZAWd88b0ygSEoYpd0BL2tFjkerMtn1vZ7dWvaNqTc,6837 +numpy/core/tests/test_protocols.py,sha256=fEXE9K9s22oiVWkX92BY-g00-uXCK-HxjZhZxxYAKFc,1168 +numpy/core/tests/test_records.py,sha256=pluit5x6jkWoPEIrHXM13L3xZuuSSiaxoXFsOdkakCU,20269 +numpy/core/tests/test_regression.py,sha256=SJo9cPTVr2SNjhgtW7boUMyNQlXxygsZ5g0oyqC8Eks,91595 +numpy/core/tests/test_scalar_ctors.py,sha256=qDIZV-tBukwAxNDhUmGtH3CemDXlS3xd_q3L52touuA,6115 +numpy/core/tests/test_scalar_methods.py,sha256=Uj-zU0zzzKAjMBdpkzsWZ3nSFj5gJkUlqi_euhOYdnU,7541 +numpy/core/tests/test_scalarbuffer.py,sha256=FSL94hriWX1_uV6Z33wB3ZXUrpmmX2-x87kNjIxUeBk,5580 +numpy/core/tests/test_scalarinherit.py,sha256=fMInDGKsiH3IS_2ejZtIcmJZ0Ry8c7kVsHx7wp5XDoM,2368 +numpy/core/tests/test_scalarmath.py,sha256=XZj_m2I2TLktJdFD1SWj2XtV8hT26VIxasDz3cAFvgA,43247 +numpy/core/tests/test_scalarprint.py,sha256=1599W5X0tjGhBnSQjalXkg6AY8eHXnr6PMqs4vYZQqs,18771 +numpy/core/tests/test_shape_base.py,sha256=D9haeuUVx3x3pOLmFQ9vUz7iU4T2bFTsPoI8HgSncFU,29723 +numpy/core/tests/test_simd.py,sha256=-L1UhIn9Eu_euLwaSU7bPRfYpWWOTb43qovoJS7Ws7w,48696 +numpy/core/tests/test_simd_module.py,sha256=OSpYhH_3QDxItyQcaW6SjXW57k2m-weRwpYOnJjCqN0,3902 +numpy/core/tests/test_strings.py,sha256=A9t1B65lFrYRLXgDJSg3mMDAe_hypIPcTMVOdAYIbU0,3835 +numpy/core/tests/test_ufunc.py,sha256=5pS2x3LACHn8GogYYad8LRAjByK7Gg9xTD9ik3d0Fm0,124907 +numpy/core/tests/test_umath.py,sha256=huHpclJqkO32k7BTflRHj8nImzg3p6yyryeS9LyHKWU,186482 +numpy/core/tests/test_umath_accuracy.py,sha256=mFcVdzXhhD9mqhzLDJVZsWfCHbjbFQ6XeEl5G8l-PTc,3897 +numpy/core/tests/test_umath_complex.py,sha256=WvZZZWeijo52RiOfx-G83bxzQOp_IJ3i9fEnUDVukLQ,23247 +numpy/core/tests/test_unicode.py,sha256=hUXIwMmoq89y_KXWzuXVyQaXvRwGjfY4TvKJsCbygEI,12775 +numpy/core/umath.py,sha256=JbT_SxnZ_3MEmjOI9UtX3CcAzX5Q-4RDlnnhDAEJ5Vo,2040 +numpy/core/umath_tests.py,sha256=TIzaDfrEHHgSc2J5kxFEibq8MOPhwSuyOZOUBsZNVSM,389 +numpy/ctypeslib.py,sha256=Po4XCWfxhwFQ1Q8x8DeayGiMCJLxREaCDkVyeladxBU,17247 +numpy/ctypeslib.pyi,sha256=A9te473aRO920iDVuyKypeVIQp-ueZK6EiI-qLSwJNg,7972 +numpy/doc/__init__.py,sha256=OYmE-F6x0CD05PCDY2MiW1HLlwB6i9vhDpk-a3r4lHY,508 +numpy/doc/__pycache__/__init__.cpython-312.pyc,, +numpy/doc/__pycache__/constants.cpython-312.pyc,, +numpy/doc/__pycache__/ufuncs.cpython-312.pyc,, +numpy/doc/constants.py,sha256=PlXoj7b4A8Aa9nADbg83uzTBRJaX8dvJmEdbn4FDPPo,9155 +numpy/doc/ufuncs.py,sha256=i1alLg19mNyCFZ2LYSOZGm--RsRN1x63U_UYU-N3x60,5357 +numpy/dtypes.py,sha256=BuBztrPQRasUmVZhXr2_NgJujdUTNhNwd59pZZHk3lA,2229 +numpy/dtypes.pyi,sha256=tIHniAYP7ALg2iT7NgSXO67jvE-zRlDod3MazEmD4M8,1315 +numpy/exceptions.py,sha256=7j7tv8cwXGZYgldyMisGmnAxAl2s4YU0vexME81yYlA,7339 +numpy/exceptions.pyi,sha256=KsZqWNvyPUEXUGR9EhZCUQF2f9EVSpBRlJUlGqRT02k,600 +numpy/f2py/__init__.py,sha256=m-ty_WiJZ4GVfV5--kJ3MFJaLXestz5Eo-4H0FPscK4,5565 +numpy/f2py/__init__.pyi,sha256=eA7uYXZr0p0aaz5rBW-EypLx9RchrvqDYtSnkEJQsYw,1087 +numpy/f2py/__main__.py,sha256=6i2jVH2fPriV1aocTY_dUFvWK18qa-zjpnISA-OpF3w,130 +numpy/f2py/__pycache__/__init__.cpython-312.pyc,, +numpy/f2py/__pycache__/__main__.cpython-312.pyc,, +numpy/f2py/__pycache__/__version__.cpython-312.pyc,, +numpy/f2py/__pycache__/_isocbind.cpython-312.pyc,, +numpy/f2py/__pycache__/_src_pyf.cpython-312.pyc,, +numpy/f2py/__pycache__/auxfuncs.cpython-312.pyc,, +numpy/f2py/__pycache__/capi_maps.cpython-312.pyc,, +numpy/f2py/__pycache__/cb_rules.cpython-312.pyc,, +numpy/f2py/__pycache__/cfuncs.cpython-312.pyc,, +numpy/f2py/__pycache__/common_rules.cpython-312.pyc,, +numpy/f2py/__pycache__/crackfortran.cpython-312.pyc,, +numpy/f2py/__pycache__/diagnose.cpython-312.pyc,, +numpy/f2py/__pycache__/f2py2e.cpython-312.pyc,, +numpy/f2py/__pycache__/f90mod_rules.cpython-312.pyc,, +numpy/f2py/__pycache__/func2subr.cpython-312.pyc,, +numpy/f2py/__pycache__/rules.cpython-312.pyc,, +numpy/f2py/__pycache__/setup.cpython-312.pyc,, +numpy/f2py/__pycache__/symbolic.cpython-312.pyc,, +numpy/f2py/__pycache__/use_rules.cpython-312.pyc,, +numpy/f2py/__version__.py,sha256=7HHdjR82FCBmftwMRyrlhcEj-8mGQb6oCH-wlUPH4Nw,34 +numpy/f2py/_backends/__init__.py,sha256=7_bA7c_xDpLc4_8vPfH32-Lxn9fcUTgjQ25srdvwvAM,299 +numpy/f2py/_backends/__pycache__/__init__.cpython-312.pyc,, +numpy/f2py/_backends/__pycache__/_backend.cpython-312.pyc,, +numpy/f2py/_backends/__pycache__/_distutils.cpython-312.pyc,, +numpy/f2py/_backends/__pycache__/_meson.cpython-312.pyc,, +numpy/f2py/_backends/_backend.py,sha256=GKb9-UaFszT045vUgVukPs1n97iyyjqahrWKxLOKNYo,1187 +numpy/f2py/_backends/_distutils.py,sha256=pxh2YURFYYSykIOvBFwVvhoNX1oSk-c30IPPhzlko-0,2383 +numpy/f2py/_backends/_meson.py,sha256=gi-nbnPFDC38sumfAjg-Q5FPu6nNkyQXTjEuVf9W9Cc,6916 +numpy/f2py/_backends/meson.build.template,sha256=oTPNMAQzS4CJ_lfEzYv-oBeJTtQuThUYVN5R6ROWpNU,1579 +numpy/f2py/_isocbind.py,sha256=zaBgpfPNRmxVG3doUIlbZIiyB990MsXiwDabrSj9HnQ,2360 +numpy/f2py/_src_pyf.py,sha256=4t6TN4ZKWciC4f1z6fwaGrpIGhHKRiwHfcrNj4FIzCg,7654 +numpy/f2py/auxfuncs.py,sha256=dNs4b2KDIcG4M1hPBvD09-Vh7CDzlPIrFscOdvL3p1o,26539 +numpy/f2py/capi_maps.py,sha256=ENjYyeZ3CCJcLwJJgmKOSYrD1KPuhpwauXqeizdV55o,30563 +numpy/f2py/cb_rules.py,sha256=5TuHbJWGjsF6yVNzKuV2tAnwdLyhcWlmdsjYlDOZOv4,24992 +numpy/f2py/cfuncs.py,sha256=KJyW7mdjmFSmxssfeegGJs5NZyF3mZMgNvOxN9-vYHQ,51913 +numpy/f2py/common_rules.py,sha256=gHB76WypbkVmhaD_RWhy8Od4zDTgj8cbDOdUdIp6PIQ,5131 +numpy/f2py/crackfortran.py,sha256=ErLdkWP8MxeyW5vVPGXwyvrxZAwymlvIBC0th2rvK74,148553 +numpy/f2py/diagnose.py,sha256=0SRXBE2hJgKJN_Rf4Zn00oKXC_Tka3efPWM47zg6BoY,5197 +numpy/f2py/f2py2e.py,sha256=5t093ZQ4xs0_0UbyaYVd2yA2EVOaOAcuU29JI-IU2Ag,27717 +numpy/f2py/f90mod_rules.py,sha256=otm3_dmVIna0eBVHLu_693s3a_82lU3pqeqDacWI37s,9594 +numpy/f2py/func2subr.py,sha256=6d2R5awuHRT4xzgfUfwS7JHTqhhAieSXcENlssD_2c4,10298 +numpy/f2py/rules.py,sha256=B4FxSYEfZ_1j_z9GulQNZ1BNrPrUvlU3ybxwTkrIxjI,62727 +numpy/f2py/setup.cfg,sha256=Fpn4sjqTl5OT5sp8haqKIRnUcTPZNM6MIvUJBU7BIhg,48 +numpy/f2py/setup.py,sha256=MmAVspT8DDTqDuL8ZJhxK62g0lcso4vqI6QNQ9CsfoQ,2422 +numpy/f2py/src/fortranobject.c,sha256=g4BKDO1_9pCu6hithKXD2oH_Mt-HH1NTnP6leCqJrzc,46017 +numpy/f2py/src/fortranobject.h,sha256=neMKotYWbHvrhW9KXz4QzQ8fzPkiQXLHHjy82vLSeog,5835 +numpy/f2py/symbolic.py,sha256=jWBoAwECCxRdWczR9r7O6UERcYmH_GbdcAReNp7cmJY,53270 +numpy/f2py/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +numpy/f2py/tests/__pycache__/__init__.cpython-312.pyc,, +numpy/f2py/tests/__pycache__/test_abstract_interface.cpython-312.pyc,, +numpy/f2py/tests/__pycache__/test_array_from_pyobj.cpython-312.pyc,, +numpy/f2py/tests/__pycache__/test_assumed_shape.cpython-312.pyc,, +numpy/f2py/tests/__pycache__/test_block_docstring.cpython-312.pyc,, +numpy/f2py/tests/__pycache__/test_callback.cpython-312.pyc,, +numpy/f2py/tests/__pycache__/test_character.cpython-312.pyc,, +numpy/f2py/tests/__pycache__/test_common.cpython-312.pyc,, +numpy/f2py/tests/__pycache__/test_compile_function.cpython-312.pyc,, +numpy/f2py/tests/__pycache__/test_crackfortran.cpython-312.pyc,, +numpy/f2py/tests/__pycache__/test_data.cpython-312.pyc,, +numpy/f2py/tests/__pycache__/test_docs.cpython-312.pyc,, +numpy/f2py/tests/__pycache__/test_f2cmap.cpython-312.pyc,, +numpy/f2py/tests/__pycache__/test_f2py2e.cpython-312.pyc,, +numpy/f2py/tests/__pycache__/test_isoc.cpython-312.pyc,, +numpy/f2py/tests/__pycache__/test_kind.cpython-312.pyc,, +numpy/f2py/tests/__pycache__/test_mixed.cpython-312.pyc,, +numpy/f2py/tests/__pycache__/test_module_doc.cpython-312.pyc,, +numpy/f2py/tests/__pycache__/test_parameter.cpython-312.pyc,, +numpy/f2py/tests/__pycache__/test_pyf_src.cpython-312.pyc,, +numpy/f2py/tests/__pycache__/test_quoted_character.cpython-312.pyc,, +numpy/f2py/tests/__pycache__/test_regression.cpython-312.pyc,, +numpy/f2py/tests/__pycache__/test_return_character.cpython-312.pyc,, +numpy/f2py/tests/__pycache__/test_return_complex.cpython-312.pyc,, +numpy/f2py/tests/__pycache__/test_return_integer.cpython-312.pyc,, +numpy/f2py/tests/__pycache__/test_return_logical.cpython-312.pyc,, +numpy/f2py/tests/__pycache__/test_return_real.cpython-312.pyc,, +numpy/f2py/tests/__pycache__/test_semicolon_split.cpython-312.pyc,, +numpy/f2py/tests/__pycache__/test_size.cpython-312.pyc,, +numpy/f2py/tests/__pycache__/test_string.cpython-312.pyc,, +numpy/f2py/tests/__pycache__/test_symbolic.cpython-312.pyc,, +numpy/f2py/tests/__pycache__/test_value_attrspec.cpython-312.pyc,, +numpy/f2py/tests/__pycache__/util.cpython-312.pyc,, +numpy/f2py/tests/src/abstract_interface/foo.f90,sha256=JFU2w98cB_XNwfrqNtI0yDTmpEdxYO_UEl2pgI_rnt8,658 +numpy/f2py/tests/src/abstract_interface/gh18403_mod.f90,sha256=gvQJIzNtvacWE0dhysxn30-iUeI65Hpq7DiE9oRauz8,105 +numpy/f2py/tests/src/array_from_pyobj/wrapmodule.c,sha256=Ff5wHYV9-OJnZuelfFWcjAibRvDkEIlbTVczTyv6TG8,7299 +numpy/f2py/tests/src/assumed_shape/.f2py_f2cmap,sha256=But9r9m4iL7EGq_haMW8IiQ4VivH0TgUozxX4pPvdpE,29 +numpy/f2py/tests/src/assumed_shape/foo_free.f90,sha256=oBwbGSlbr9MkFyhVO2aldjc01dr9GHrMrSiRQek8U64,460 +numpy/f2py/tests/src/assumed_shape/foo_mod.f90,sha256=rfzw3QdI-eaDSl-hslCgGpd5tHftJOVhXvb21Y9Gf6M,499 +numpy/f2py/tests/src/assumed_shape/foo_use.f90,sha256=rmT9k4jP9Ru1PLcGqepw9Jc6P9XNXM0axY7o4hi9lUw,269 +numpy/f2py/tests/src/assumed_shape/precision.f90,sha256=r08JeTVmTTExA-hYZ6HzaxVwBn1GMbPAuuwBhBDtJUk,130 +numpy/f2py/tests/src/block_docstring/foo.f,sha256=y7lPCPu7_Fhs_Tf2hfdpDQo1bhtvNSKRaZAOpM_l3dg,97 +numpy/f2py/tests/src/callback/foo.f,sha256=C1hjfpRCQWiOVVzIHqnsYcnLrqQcixrnHCn8hd9GhVk,1254 +numpy/f2py/tests/src/callback/gh17797.f90,sha256=_Nrl0a2HgUbtymGU0twaJ--7rMa1Uco2A3swbWvHoMo,148 +numpy/f2py/tests/src/callback/gh18335.f90,sha256=NraOyKIXyvv_Y-3xGnmTjtNjW2Znsnlk8AViI8zfovc,506 +numpy/f2py/tests/src/callback/gh25211.f,sha256=a2sxlQhtDVbYn8KOKHUYqwc-aCFt7sDPSnJsXFG35uI,179 +numpy/f2py/tests/src/callback/gh25211.pyf,sha256=FWxo0JWQlw519BpZV8PoYeI_FZ_K6C-3Wk6gLrfBPlw,447 +numpy/f2py/tests/src/cli/gh_22819.pyf,sha256=5rvOfCv-wSosB354LC9pExJmMoSHnbGZGl_rtA2fogA,142 +numpy/f2py/tests/src/cli/hi77.f,sha256=ttyI6vAP3qLnDqy82V04XmoqrXNM6uhMvvLri2p0dq0,71 +numpy/f2py/tests/src/cli/hiworld.f90,sha256=QWOLPrTxYQu1yrEtyQMbM0fE9M2RmXe7c185KnD5x3o,51 +numpy/f2py/tests/src/common/block.f,sha256=GQ0Pd-VMX3H3a-__f2SuosSdwNXHpBqoGnQDjf8aG9g,224 +numpy/f2py/tests/src/common/gh19161.f90,sha256=BUejyhqpNVfHZHQ-QC7o7ZSo7lQ6YHyX08lSmQqs6YM,193 +numpy/f2py/tests/src/crackfortran/accesstype.f90,sha256=-5Din7YlY1TU7tUHD2p-_DSTxGBpDsWYNeT9WOwGhno,208 +numpy/f2py/tests/src/crackfortran/data_common.f,sha256=ZSUAh3uhn9CCF-cYqK5TNmosBGPfsuHBIEfudgysun4,193 +numpy/f2py/tests/src/crackfortran/data_multiplier.f,sha256=jYrJKZWF_59JF9EMOSALUjn0UupWvp1teuGpcL5s1Sc,197 +numpy/f2py/tests/src/crackfortran/data_stmts.f90,sha256=19YO7OGj0IksyBlmMLZGRBQLjoE3erfkR4tFvhznvvE,693 +numpy/f2py/tests/src/crackfortran/data_with_comments.f,sha256=hoyXw330VHh8duMVmAQZjr1lgLVF4zFCIuEaUIrupv0,175 +numpy/f2py/tests/src/crackfortran/foo_deps.f90,sha256=CaH7mnWTG7FcnJe2vXN_0zDbMadw6NCqK-JJ2HmDjK8,128 +numpy/f2py/tests/src/crackfortran/gh15035.f,sha256=jJly1AzF5L9VxbVQ0vr-sf4LaUo4eQzJguhuemFxnvg,375 +numpy/f2py/tests/src/crackfortran/gh17859.f,sha256=7K5dtOXGuBDAENPNCt-tAGJqTfNKz5OsqVSk16_e7Es,340 +numpy/f2py/tests/src/crackfortran/gh22648.pyf,sha256=qZHPRNQljIeYNwbqPLxREnOrSdVV14f3fnaHqB1M7c0,241 +numpy/f2py/tests/src/crackfortran/gh23533.f,sha256=w3tr_KcY3s7oSWGDmjfMHv5h0RYVGUpyXquNdNFOJQg,126 +numpy/f2py/tests/src/crackfortran/gh23598.f90,sha256=41W6Ire-5wjJTTg6oAo7O1WZfd1Ug9vvNtNgHS5MhEU,101 +numpy/f2py/tests/src/crackfortran/gh23598Warn.f90,sha256=1v-hMCT_K7prhhamoM20nMU9zILam84Hr-imck_dYYk,205 +numpy/f2py/tests/src/crackfortran/gh23879.f90,sha256=LWDJTYR3t9h1IsrKC8dVXZlBfWX7clLeU006X6Ow8oI,332 +numpy/f2py/tests/src/crackfortran/gh2848.f90,sha256=gPNasx98SIf7Z9ibk_DHiGKCvl7ERtsfoGXiFDT7FbM,282 +numpy/f2py/tests/src/crackfortran/operators.f90,sha256=-Fc-qjW1wBr3Dkvdd5dMTrt0hnjnV-1AYo-NFWcwFSo,1184 +numpy/f2py/tests/src/crackfortran/privatemod.f90,sha256=7bubZGMIn7iD31wDkjF1TlXCUM7naCIK69M9d0e3y-U,174 +numpy/f2py/tests/src/crackfortran/publicmod.f90,sha256=Pnwyf56Qd6W3FUH-ZMgnXEYkb7gn18ptNTdwmGan0Jo,167 +numpy/f2py/tests/src/crackfortran/pubprivmod.f90,sha256=eYpJwBYLKGOxVbKgEqfny1znib-b7uYhxcRXIf7uwXg,165 +numpy/f2py/tests/src/crackfortran/unicode_comment.f90,sha256=aINLh6GlfTwFewxvDoqnMqwuCNb4XAqi5Nj5vXguXYs,98 +numpy/f2py/tests/src/f2cmap/.f2py_f2cmap,sha256=iUOtfHd3OuT1Rz2-yiSgt4uPKGvCt5AzQ1iygJt_yjg,82 +numpy/f2py/tests/src/f2cmap/isoFortranEnvMap.f90,sha256=iJCD8a8MUTmuPuedbcmxW54Nr4alYuLhksBe1sHS4K0,298 +numpy/f2py/tests/src/isocintrin/isoCtests.f90,sha256=jcw-fzrFh0w5U66uJYfeUW4gv94L5MnWQ_NpsV9y0oI,998 +numpy/f2py/tests/src/kind/foo.f90,sha256=zIHpw1KdkWbTzbXb73hPbCg4N2Htj3XL8DIwM7seXpo,347 +numpy/f2py/tests/src/mixed/foo.f,sha256=90zmbSHloY1XQYcPb8B5d9bv9mCZx8Z8AMTtgDwJDz8,85 +numpy/f2py/tests/src/mixed/foo_fixed.f90,sha256=pxKuPzxF3Kn5khyFq9ayCsQiolxB3SaNtcWaK5j6Rv4,179 +numpy/f2py/tests/src/mixed/foo_free.f90,sha256=fIQ71wrBc00JUAVUj_r3QF9SdeNniBiMw6Ly7CGgPWU,139 +numpy/f2py/tests/src/module_data/mod.mod,sha256=EkjrU7NTZrOH68yKrz6C_eyJMSFSxGgC2yMQT9Zscek,412 +numpy/f2py/tests/src/module_data/module_data_docstring.f90,sha256=tDZ3fUlazLL8ThJm3VwNGJ75QIlLcW70NnMFv-JA4W0,224 +numpy/f2py/tests/src/negative_bounds/issue_20853.f90,sha256=fdOPhRi7ipygwYCXcda7p_dlrws5Hd2GlpF9EZ-qnck,157 +numpy/f2py/tests/src/parameter/constant_both.f90,sha256=-bBf2eqHb-uFxgo6Q7iAtVUUQzrGFqzhHDNaxwSICfQ,1939 +numpy/f2py/tests/src/parameter/constant_compound.f90,sha256=re7pfzcuaquiOia53UT7qNNrTYu2euGKOF4IhoLmT6g,469 +numpy/f2py/tests/src/parameter/constant_integer.f90,sha256=nEmMLitKoSAG7gBBEQLWumogN-KS3DBZOAZJWcSDnFw,612 +numpy/f2py/tests/src/parameter/constant_non_compound.f90,sha256=IcxESVLKJUZ1k9uYKoSb8Hfm9-O_4rVnlkiUU2diy8Q,609 +numpy/f2py/tests/src/parameter/constant_real.f90,sha256=quNbDsM1Ts2rN4WtPO67S9Xi_8l2cXabWRO00CPQSSQ,610 +numpy/f2py/tests/src/quoted_character/foo.f,sha256=WjC9D9171fe2f7rkUAZUvik9bkIf9adByfRGzh6V0cM,482 +numpy/f2py/tests/src/regression/gh25337/data.f90,sha256=9Uz8CHB9i3_mjC3cTOmkTgPAF5tWSwYacG3MUrU-SY0,180 +numpy/f2py/tests/src/regression/gh25337/use_data.f90,sha256=WATiDGAoCKnGgMzm_iMgmfVU0UKOQlk5Fm0iXCmPAkE,179 +numpy/f2py/tests/src/regression/inout.f90,sha256=CpHpgMrf0bqA1W3Ozo3vInDz0RP904S7LkpdAH6ODck,277 +numpy/f2py/tests/src/return_character/foo77.f,sha256=WzDNF3d_hUDSSZjtxd3DtE-bSx1ilOMEviGyYHbcFgM,980 +numpy/f2py/tests/src/return_character/foo90.f90,sha256=ULcETDEt7gXHRzmsMhPsGG4o3lGrcx-FEFaJsPGFKyA,1248 +numpy/f2py/tests/src/return_complex/foo77.f,sha256=8ECRJkfX82oFvGWKbIrCvKjf5QQQClx4sSEvsbkB6A8,973 +numpy/f2py/tests/src/return_complex/foo90.f90,sha256=c1BnrtWwL2dkrTr7wvlEqNDg59SeNMo3gyJuGdRwcDw,1238 +numpy/f2py/tests/src/return_integer/foo77.f,sha256=_8k1evlzBwvgZ047ofpdcbwKdF8Bm3eQ7VYl2Y8b5kA,1178 +numpy/f2py/tests/src/return_integer/foo90.f90,sha256=bzxbYtofivGRYH35Ang9ScnbNsVERN8-6ub5-eI-LGQ,1531 +numpy/f2py/tests/src/return_logical/foo77.f,sha256=FxiF_X0HkyXHzJM2rLyTubZJu4JB-ObLnVqfZwAQFl8,1188 +numpy/f2py/tests/src/return_logical/foo90.f90,sha256=9KmCe7yJYpi4ftkKOM3BCDnPOdBPTbUNrKxY3p37O14,1531 +numpy/f2py/tests/src/return_real/foo77.f,sha256=ZTrzb6oDrIDPlrVWP3Bmtkbz3ffHaaSQoXkfTGtCuFE,933 +numpy/f2py/tests/src/return_real/foo90.f90,sha256=gZuH5lj2lG6gqHlH766KQ3J4-Ero-G4WpOOo2MG3ohU,1194 +numpy/f2py/tests/src/size/foo.f90,sha256=IlFAQazwBRr3zyT7v36-tV0-fXtB1d7WFp6S1JVMstg,815 +numpy/f2py/tests/src/string/char.f90,sha256=ihr_BH9lY7eXcQpHHDQhFoKcbu7VMOX5QP2Tlr7xlaM,618 +numpy/f2py/tests/src/string/fixed_string.f90,sha256=5n6IkuASFKgYICXY9foCVoqndfAY0AQZFEK8L8ARBGM,695 +numpy/f2py/tests/src/string/gh24008.f,sha256=UA8Pr-_yplfOFmc6m4v9ryFQ8W9OulaglulefkFWD68,217 +numpy/f2py/tests/src/string/gh24662.f90,sha256=-Tp9Kd1avvM7AIr8ZukFA9RVr-wusziAnE8AvG9QQI4,197 +numpy/f2py/tests/src/string/gh25286.f90,sha256=2EpxvC-0_dA58MBfGQcLyHzpZgKcMf_W9c73C_Mqnok,304 +numpy/f2py/tests/src/string/gh25286.pyf,sha256=GjgWKh1fHNdPGRiX5ek60i1XSeZsfFalydWqjISPVV8,381 +numpy/f2py/tests/src/string/gh25286_bc.pyf,sha256=6Y9zU66NfcGhTXlFOdFjCSMSwKXpq5ZfAe3FwpkAsm4,384 +numpy/f2py/tests/src/string/scalar_string.f90,sha256=ACxV2i6iPDk-a6L_Bs4jryVKYJMEGUTitEIYTjbJes4,176 +numpy/f2py/tests/src/string/string.f,sha256=shr3fLVZaa6SyUJFYIF1OZuhff8v5lCwsVNBU2B-3pk,248 +numpy/f2py/tests/src/value_attrspec/gh21665.f90,sha256=JC0FfVXsnB2lZHb-nGbySnxv_9VHAyD0mKaLDowczFU,190 +numpy/f2py/tests/test_abstract_interface.py,sha256=C8-ly0_TqkmpQNZmwPHwo2IV2MBH0jQEjAhpqHrg8Y4,832 +numpy/f2py/tests/test_array_from_pyobj.py,sha256=Txff89VUeEhWqUCRVybIqsqH4YQvpk4Uyjmh_XjyMi0,24049 +numpy/f2py/tests/test_assumed_shape.py,sha256=FeaqtrWyBf5uyArcmI0D2e_f763aSMpgU3QmdDXe-tA,1466 +numpy/f2py/tests/test_block_docstring.py,sha256=SEpuq73T9oVtHhRVilFf1xF7nb683d4-Kv7V0kfL4AA,564 +numpy/f2py/tests/test_callback.py,sha256=cReSlVjgnoT74wmtNn-oEIZiJUTfRX7ljjlqJi716IQ,6494 +numpy/f2py/tests/test_character.py,sha256=3ugjM1liymMRbY8wub1eiap-jdyNYVHxlNZBqNoRLe4,21868 +numpy/f2py/tests/test_common.py,sha256=m7TTSJt5zUZKJF-MQUeTtCyxW7YwRBSETINXGPFu8S4,896 +numpy/f2py/tests/test_compile_function.py,sha256=9d_FZ8P2wbIlQ2qPDRrsFqPb4nMH8tiWqYZN-P_shCs,4186 +numpy/f2py/tests/test_crackfortran.py,sha256=y1x3U-jlQWD5rmTXz1I2RlTz7LEfbI6qxCDkR5fzPwY,13441 +numpy/f2py/tests/test_data.py,sha256=HFcmPYbiveKa-swJ8x8XlRR9sM0ESB9FEN-txZnHTok,2876 +numpy/f2py/tests/test_docs.py,sha256=jqtuHE5ZjxP4D8Of3Fkzz36F8_0qKbeS040_m0ac4v4,1662 +numpy/f2py/tests/test_f2cmap.py,sha256=p-Sylbr3ctdKT3UQV9FzpCuYPH5U7Vyn8weXFAjiI9o,391 +numpy/f2py/tests/test_f2py2e.py,sha256=eoswH-daMEBlueoVpxXrDloahCpr0RLzHbr3zBHOsjk,25423 +numpy/f2py/tests/test_isoc.py,sha256=_nPTPxNEEagiKriZBeFNesOattIlHDzaNKmj35xxDBY,1406 +numpy/f2py/tests/test_kind.py,sha256=aOMQSBoD_dw49acKN25_abEvQBLI27DsnWIb9CNpSAE,1671 +numpy/f2py/tests/test_mixed.py,sha256=Ctuw-H7DxhPjSt7wZdJ2xffawIoEBCPWc5F7PSkY4HY,848 +numpy/f2py/tests/test_module_doc.py,sha256=sjCXWIKrqMD1NQ1DUAzgQqkjS5w9h9gvM_Lj29Rdcrg,863 +numpy/f2py/tests/test_parameter.py,sha256=ADI7EV_CM4ztICpqHqeq8LI-WdB6cX0ttatdRdjbsUA,3941 +numpy/f2py/tests/test_pyf_src.py,sha256=eD0bZu_GWfoCq--wWqEKRf-F2h5AwoTyO6GMA9wJPr4,1135 +numpy/f2py/tests/test_quoted_character.py,sha256=cpjMdrHwimnkoJkXd_W_FSlh43oWytY5VHySW9oskO4,454 +numpy/f2py/tests/test_regression.py,sha256=v_6RDQr6IcMmbCMElfzRSLPgZhHnH5l99uztrbJAzqE,2532 +numpy/f2py/tests/test_return_character.py,sha256=18HJtiRwQ7a_2mdPUonD5forKWZJEapD-Vi1DsbTjVs,1493 +numpy/f2py/tests/test_return_complex.py,sha256=BZIIqQ1abdiPLgVmu03_q37yCtND0ijxGSMhGz2Wf-o,2397 +numpy/f2py/tests/test_return_integer.py,sha256=t--9UsdLF9flLTQv7a0KTSVoBuoDtTnmOG2QIFPINVc,1758 +numpy/f2py/tests/test_return_logical.py,sha256=XCmp8E8I6BOeNYF59HjSFAdv1hM9WaDvl8UDS10_05o,2017 +numpy/f2py/tests/test_return_real.py,sha256=ATek5AM7dCCPeIvoMOQIt5yFNFzKrFb1Kno8B4M0rn4,3235 +numpy/f2py/tests/test_semicolon_split.py,sha256=_Mdsi84lES18pPjl9J-QsbGttV4tPFFjZvJvejNcqPc,1635 +numpy/f2py/tests/test_size.py,sha256=q6YqQvcyqdXJeWbGijTiCbxyEG3EkPcvT8AlAW6RCMo,1164 +numpy/f2py/tests/test_string.py,sha256=5xZOfdReoHnId0950XfmtfduPPfBbtMkzBoXMtygvMk,2962 +numpy/f2py/tests/test_symbolic.py,sha256=28quk2kTKfWhKe56n4vINJ8G9weKBfc7HysMlE9J3_g,18341 +numpy/f2py/tests/test_value_attrspec.py,sha256=rWwJBfE2qGzqilZZurJ-7ucNoJDICye6lLetQSLFees,323 +numpy/f2py/tests/util.py,sha256=bEhG699c4bLVPR2WR8fV67avgX6kH5I74SicGb7Z7T4,11167 +numpy/f2py/use_rules.py,sha256=3pTDOPur6gbPHPtwuMJPQvpnUMw39Law1KFSH0coB_0,3527 +numpy/fft/__init__.py,sha256=HqjmF6s_dh0Ri4UZzUDtOKbNUyfAfJAWew3e3EL_KUk,8175 +numpy/fft/__init__.pyi,sha256=vD9Xzz5r13caF4AVL87Y4U9KOj9ic25Vci_wb3dmgpk,550 +numpy/fft/__pycache__/__init__.cpython-312.pyc,, +numpy/fft/__pycache__/_pocketfft.cpython-312.pyc,, +numpy/fft/__pycache__/helper.cpython-312.pyc,, +numpy/fft/_pocketfft.py,sha256=Xkm8wcP4JyBNMbp0ZoHIWhNDlgliX24RzrDuo29uRks,52897 +numpy/fft/_pocketfft.pyi,sha256=S6-ylUuHbgm8vNbh7tLru6K2R5SJzE81BC_Sllm6QrQ,2371 +numpy/fft/_pocketfft_internal.cpython-312-x86_64-linux-gnu.so,sha256=ONIiSfNRsdUOkmnFloif_GOGOevBUMHnX8n1Wg8zGrU,97008 +numpy/fft/helper.py,sha256=aNj1AcLvtfoX26RiLOwcR-k2QSMuBZkGj2Fu0CeFPJs,6154 +numpy/fft/helper.pyi,sha256=NLTEjy2Gz1aAMDZwCgssIyUne0ubjJqukfYkpsL3gXM,1176 +numpy/fft/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +numpy/fft/tests/__pycache__/__init__.cpython-312.pyc,, +numpy/fft/tests/__pycache__/test_helper.cpython-312.pyc,, +numpy/fft/tests/__pycache__/test_pocketfft.cpython-312.pyc,, +numpy/fft/tests/test_helper.py,sha256=whgeaQ8PzFf3B1wkbXobGZ5sF4WxPp4gf1UPUVZest8,6148 +numpy/fft/tests/test_pocketfft.py,sha256=RdeCCvUQmJYVvccOJwToobTKDg9yzUL06o9MkPmRfmI,12895 +numpy/lib/__init__.py,sha256=XMPNJkG_mQ__xuvbf0OcpotgMbA9owt10ZHYVnYHq8E,2713 +numpy/lib/__init__.pyi,sha256=y5ANokFm7EkrlNoHdeQm1FsUhLFxkYtLuanCbsWrGio,5596 +numpy/lib/__pycache__/__init__.cpython-312.pyc,, +numpy/lib/__pycache__/_datasource.cpython-312.pyc,, +numpy/lib/__pycache__/_iotools.cpython-312.pyc,, +numpy/lib/__pycache__/_version.cpython-312.pyc,, +numpy/lib/__pycache__/arraypad.cpython-312.pyc,, +numpy/lib/__pycache__/arraysetops.cpython-312.pyc,, +numpy/lib/__pycache__/arrayterator.cpython-312.pyc,, +numpy/lib/__pycache__/format.cpython-312.pyc,, +numpy/lib/__pycache__/function_base.cpython-312.pyc,, +numpy/lib/__pycache__/histograms.cpython-312.pyc,, +numpy/lib/__pycache__/index_tricks.cpython-312.pyc,, +numpy/lib/__pycache__/mixins.cpython-312.pyc,, +numpy/lib/__pycache__/nanfunctions.cpython-312.pyc,, +numpy/lib/__pycache__/npyio.cpython-312.pyc,, +numpy/lib/__pycache__/polynomial.cpython-312.pyc,, +numpy/lib/__pycache__/recfunctions.cpython-312.pyc,, +numpy/lib/__pycache__/scimath.cpython-312.pyc,, +numpy/lib/__pycache__/setup.cpython-312.pyc,, +numpy/lib/__pycache__/shape_base.cpython-312.pyc,, +numpy/lib/__pycache__/stride_tricks.cpython-312.pyc,, +numpy/lib/__pycache__/twodim_base.cpython-312.pyc,, +numpy/lib/__pycache__/type_check.cpython-312.pyc,, +numpy/lib/__pycache__/ufunclike.cpython-312.pyc,, +numpy/lib/__pycache__/user_array.cpython-312.pyc,, +numpy/lib/__pycache__/utils.cpython-312.pyc,, +numpy/lib/_datasource.py,sha256=CDF3im6IxdY3Mu6fwRQmkSEBmXS3kQVInQ4plXsoX9c,22631 +numpy/lib/_iotools.py,sha256=Yg9HCfPg4tbhbdgLPcxSMiZXq1xDprvJKLebLwhDszY,30868 +numpy/lib/_version.py,sha256=6vK7czNSB_KrWx2rZJzJ1pyOc73Q07hAgfLB5ItUCnU,4855 +numpy/lib/_version.pyi,sha256=B572hyWrUWG-TAAAXrNNAT4AgyUAmJ4lvgpwMkDzunk,633 +numpy/lib/arraypad.py,sha256=bKP7ZS9NYFYzqSk8OnpFLFrMsua4m_hcqFsi7cGkrJE,31803 +numpy/lib/arraypad.pyi,sha256=ADXphtAORYl3EqvE5qs_u32B_TALKSOtF43jOLmoxRw,1728 +numpy/lib/arraysetops.py,sha256=GJ2RhkzIJmIbwyG6h3LOFTPXg62kM9tcV1a-7tdbVuU,33655 +numpy/lib/arraysetops.pyi,sha256=6X-5l5Yss_9y10LYyIsDLbGX77vt7PtVLDqxOlSRPfY,8372 +numpy/lib/arrayterator.py,sha256=BQ97S00zvfURUZfes0GZo-5hydYNRuvwX1I1bLzeRik,7063 +numpy/lib/arrayterator.pyi,sha256=f7Pwp83_6DiMYmJGUsffncM-FRAynB1iYGvhmHM_SZE,1537 +numpy/lib/format.py,sha256=T8qJMyG2DDVjjYNNpUvBgfA9tCo23IS0w9byRB6twwQ,34769 +numpy/lib/format.pyi,sha256=YWBxC3GdsZ7SKBN8I7nMwWeVuFD1aT9d-VJ8zE4-P-o,748 +numpy/lib/function_base.py,sha256=IhhgfSmYJE-dHoUOMXHPiGYXso-NdXPpLXF9y0gEA6I,189172 +numpy/lib/function_base.pyi,sha256=KWaC5UOBANU4hiIoN2eptE4HYsm4vgp_8BMFV1Y3JX4,16585 +numpy/lib/histograms.py,sha256=xsj_qpaZoI2Bv1FBpY8mIMPJrYRiuIBszn_6kO7YFRA,37778 +numpy/lib/histograms.pyi,sha256=hNwR2xYWkgJCP-nfRGxc-EgHLTD3qm4zmWXthZLt08M,995 +numpy/lib/index_tricks.py,sha256=4PEvXk6VFTkttMViYBVC4yDhyOiKIon6JpIm0d_CmNg,31346 +numpy/lib/index_tricks.pyi,sha256=D2nkNXOB9Vea1PfMaTn94OGBGayjTaQ-bKMsjDmYpak,4251 +numpy/lib/mixins.py,sha256=y6_MzQuiNjv-1EFVROqv2y2cAJi5X4rQYzbZCyUyXgw,7071 +numpy/lib/mixins.pyi,sha256=h9N1kbZsUntF0zjOxPYeD_rCB2dMiG35TYYPl9ymkI4,3117 +numpy/lib/nanfunctions.py,sha256=6EjzydZlugIzfiENKtC4ycZ2Nckt8ZQg5v6D6tX1SiU,65775 +numpy/lib/nanfunctions.pyi,sha256=oPqAfCinmBL85Ji7ko4QlzAzLAK9nZL0t2_CllEbCEU,606 +numpy/lib/npyio.py,sha256=NUjtFvAmPdTjwJQ-ia-xbCr849M_M6NilP5IHfkKaRg,97316 +numpy/lib/npyio.pyi,sha256=SUFWJh90vWZCdd6GCSGbfYeXKlWut0XY_SHvZJc8yqY,9728 +numpy/lib/polynomial.py,sha256=6Aw3_2vdbh4urERQ6NaPhf9a_T1o1o6cjm3fb5Z3_YE,44133 +numpy/lib/polynomial.pyi,sha256=GerIpQnf5LdtFMOy9AxhOTqUyfn57k4MxqEYrfdckWE,6958 +numpy/lib/recfunctions.py,sha256=-90AbWWvVFOqVUPLh9K9NYdKUHYIgSEyg2Y35MnOVUA,59423 +numpy/lib/scimath.py,sha256=T4ITysZgqhY1J8IxyXCtioHjMTg2ci-4i3mr9TBF2UA,15037 +numpy/lib/scimath.pyi,sha256=E2roKJzMFwWSyhLu8UPUr54WOpxF8jp_pyXYBgsUSQ8,2883 +numpy/lib/setup.py,sha256=0K5NJKuvKvNEWp-EX7j0ODi3ZQQgIMHobzSFJq3G7yM,405 +numpy/lib/shape_base.py,sha256=AhCO9DEyysE-P-QJF9ryUtJ1ghU4_0mORhAJ59poObU,38947 +numpy/lib/shape_base.pyi,sha256=bGJhLA_RvUpVTiDFgCV-1rUjV8e1qCh0gK_3PLgXA_U,5341 +numpy/lib/stride_tricks.py,sha256=brY5b-0YQJuIH2CavfpIinMolyTUv5k9DUvLoZ-imis,17911 +numpy/lib/stride_tricks.pyi,sha256=0pQ4DP9l6g21q2Ajv6dJFRWMr9auPGTNV9BmZUbogPY,1747 +numpy/lib/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +numpy/lib/tests/__pycache__/__init__.cpython-312.pyc,, +numpy/lib/tests/__pycache__/test__datasource.cpython-312.pyc,, +numpy/lib/tests/__pycache__/test__iotools.cpython-312.pyc,, +numpy/lib/tests/__pycache__/test__version.cpython-312.pyc,, +numpy/lib/tests/__pycache__/test_arraypad.cpython-312.pyc,, +numpy/lib/tests/__pycache__/test_arraysetops.cpython-312.pyc,, +numpy/lib/tests/__pycache__/test_arrayterator.cpython-312.pyc,, +numpy/lib/tests/__pycache__/test_financial_expired.cpython-312.pyc,, +numpy/lib/tests/__pycache__/test_format.cpython-312.pyc,, +numpy/lib/tests/__pycache__/test_function_base.cpython-312.pyc,, +numpy/lib/tests/__pycache__/test_histograms.cpython-312.pyc,, +numpy/lib/tests/__pycache__/test_index_tricks.cpython-312.pyc,, +numpy/lib/tests/__pycache__/test_io.cpython-312.pyc,, +numpy/lib/tests/__pycache__/test_loadtxt.cpython-312.pyc,, +numpy/lib/tests/__pycache__/test_mixins.cpython-312.pyc,, +numpy/lib/tests/__pycache__/test_nanfunctions.cpython-312.pyc,, +numpy/lib/tests/__pycache__/test_packbits.cpython-312.pyc,, +numpy/lib/tests/__pycache__/test_polynomial.cpython-312.pyc,, +numpy/lib/tests/__pycache__/test_recfunctions.cpython-312.pyc,, +numpy/lib/tests/__pycache__/test_regression.cpython-312.pyc,, +numpy/lib/tests/__pycache__/test_shape_base.cpython-312.pyc,, +numpy/lib/tests/__pycache__/test_stride_tricks.cpython-312.pyc,, +numpy/lib/tests/__pycache__/test_twodim_base.cpython-312.pyc,, +numpy/lib/tests/__pycache__/test_type_check.cpython-312.pyc,, +numpy/lib/tests/__pycache__/test_ufunclike.cpython-312.pyc,, +numpy/lib/tests/__pycache__/test_utils.cpython-312.pyc,, +numpy/lib/tests/data/py2-objarr.npy,sha256=F4cyUC-_TB9QSFLAo2c7c44rC6NUYIgrfGx9PqWPSKk,258 +numpy/lib/tests/data/py2-objarr.npz,sha256=xo13HBT0FbFZ2qvZz0LWGDb3SuQASSaXh7rKfVcJjx4,366 +numpy/lib/tests/data/py3-objarr.npy,sha256=pTTVh8ezp-lwAK3fkgvdKU8Arp5NMKznVD-M6Ex_uA0,341 +numpy/lib/tests/data/py3-objarr.npz,sha256=qQR0gS57e9ta16d_vCQjaaKM74gPdlwCPkp55P-qrdw,449 +numpy/lib/tests/data/python3.npy,sha256=X0ad3hAaLGXig9LtSHAo-BgOvLlFfPYMnZuVIxRmj-0,96 +numpy/lib/tests/data/win64python2.npy,sha256=agOcgHVYFJrV-nrRJDbGnUnF4ZTPYXuSeF-Mtg7GMpc,96 +numpy/lib/tests/test__datasource.py,sha256=65KXfUUvp8wXSqgQisuYlkhg-qHjBV5FXYetL8Ba-rc,10571 +numpy/lib/tests/test__iotools.py,sha256=HerCqvDE07JxjFQlWEfpZO7lC9z0Sbr3z20GSutoCPs,13743 +numpy/lib/tests/test__version.py,sha256=aO3YgkAohLsLzCNQ7vjIwdpFUMz0cPLbcuuxIkjuN74,1999 +numpy/lib/tests/test_arraypad.py,sha256=obohHbyM0gPYPUkd7iJSOSiDqyqtJsjDNtQX68NC4lM,54830 +numpy/lib/tests/test_arraysetops.py,sha256=5-T1MVhfIMivat8Z47GZw0ZaR811W_FskM1bAXnFyLU,35912 +numpy/lib/tests/test_arrayterator.py,sha256=AYs2SwV5ankgwnvKI9RSO1jZck118nu3SyZ4ngzZNso,1291 +numpy/lib/tests/test_financial_expired.py,sha256=yq5mqGMvqpkiiw9CuZhJgrYa7Squj1mXr_G-IvAFgwI,247 +numpy/lib/tests/test_format.py,sha256=xV0oi1eoRnVwAAhSOcPFQHQWF7TfsROtDYShQLPtdaA,41028 +numpy/lib/tests/test_function_base.py,sha256=DBKugIUEFTMP7g6iL1bk986E6ldCrcNdBCWOJbQla_Y,157830 +numpy/lib/tests/test_histograms.py,sha256=16_XJp-eFgsuM8B4mDQpQ4w_Ib29Hg0EPO-WFsdaFWA,32815 +numpy/lib/tests/test_index_tricks.py,sha256=Vjz25Y6H_ih0iEE2AG0kaxO9U8PwcXSrofzqnN4XBwI,20256 +numpy/lib/tests/test_io.py,sha256=3Tow1pucrQ7z7osNN4a2grBYUoBGNkQEhjmCjXT6Vag,107891 +numpy/lib/tests/test_loadtxt.py,sha256=gwcDJDJmLJRMLpg322yjQ1IzI505w9EqJoq4DmDPCdI,38560 +numpy/lib/tests/test_mixins.py,sha256=Wivwz3XBWsEozGzrzsyyvL3qAuE14t1BHk2LPm9Z9Zc,7030 +numpy/lib/tests/test_nanfunctions.py,sha256=01r_mmTCvKVdZuOGTEHNDZXrMS724us_jwZANzCd74A,47609 +numpy/lib/tests/test_packbits.py,sha256=OWGAd5g5GG0gl7WHqNfwkZ7G-2rrtLt2sI854PG4nnw,17546 +numpy/lib/tests/test_polynomial.py,sha256=URouxJpr8FQ5hiKybqhtOcLA7e-3hj4kWzjLBROByyA,11395 +numpy/lib/tests/test_recfunctions.py,sha256=6jzouPEQ7Uhtj8_-W5yTI6ymNp2nLgmdHzxdd74jVuM,44001 +numpy/lib/tests/test_regression.py,sha256=KzGFkhTcvEG97mymoOQ2hP2CEr2nPZou0Ztf4-WaXCs,8257 +numpy/lib/tests/test_shape_base.py,sha256=2iQCEFR6evVpF8woaenxUOzooHkfuMYkBaUj8ecyJ-E,26817 +numpy/lib/tests/test_stride_tricks.py,sha256=wprpWWH5eq07DY7rzG0WDv5fMtLxzRQz6fm6TZWlScQ,22849 +numpy/lib/tests/test_twodim_base.py,sha256=ll-72RhqCItIPB97nOWhH7H292h4nVIX_w1toKTPMUg,18841 +numpy/lib/tests/test_type_check.py,sha256=lxCH5aApWVYhhSoDQSLDTCHLVHuK2c-jBbnfnZUrOaA,15114 +numpy/lib/tests/test_ufunclike.py,sha256=4hSnXGlSC8HE-_pRRMzD8-HI4hGHqsAWu1pD0o2kPI0,2982 +numpy/lib/tests/test_utils.py,sha256=RVAxrzSFu6N3C4_jIgAlTDOWF_B7wr2v1Y20dX5upYM,6218 +numpy/lib/twodim_base.py,sha256=Mvzn_PyShIb9m7nJjJ4IetdxwmLYEsCPHvJoK7n2viU,32947 +numpy/lib/twodim_base.pyi,sha256=xFRcEVJdDj4mrXW_6iVP1lTMoJx4QJjYRD3o2_9f2eY,5370 +numpy/lib/type_check.py,sha256=_EOtB296nFYlNT7ztBYoC_yK9aycIb0KTmRjvzVdZNg,19954 +numpy/lib/type_check.pyi,sha256=LPvAvIxU-p5i_Qe-ic7hEvo4OTfSrNpplxMG7OAZe8Q,5571 +numpy/lib/ufunclike.py,sha256=_ceBGbGCMOd3u_h2UVzyaRK6ZY7ryoJ0GJB7zqcJG3w,6325 +numpy/lib/ufunclike.pyi,sha256=hLxcYfQprh1tTY_UO2QscA3Hd9Zd7cVGXIINZLhMFqY,1293 +numpy/lib/user_array.py,sha256=LE958--CMkBI2r3l1SQxmCHdCSw6HY6-RhWCnduzGA4,7721 +numpy/lib/utils.py,sha256=6NdleaELZiqARdj-ECZjxtwLf1bqklOcK43m9yoZefs,37804 +numpy/lib/utils.pyi,sha256=mVHVzWuc2-M3Oz60lFsbok0v8LH_HRHMjZpXwrtzF_c,2360 +numpy/linalg/__init__.py,sha256=mpdlEXWtTvpF7In776ONLwp6RIyo4U_GLPT1L1eIJnw,1813 +numpy/linalg/__init__.pyi,sha256=XBy4ocuypsRVflw_mbSTUhR4N5Roemu6w5SfeVwbkAc,620 +numpy/linalg/__pycache__/__init__.cpython-312.pyc,, +numpy/linalg/__pycache__/linalg.cpython-312.pyc,, +numpy/linalg/_umath_linalg.cpython-312-x86_64-linux-gnu.so,sha256=iCLnctdD1AWYPxucazS3BN0pd4CJDcJFRU8Qga31Ckw,216793 +numpy/linalg/lapack_lite.cpython-312-x86_64-linux-gnu.so,sha256=UAZPuN2wY1u7YCi4990o-QwErZqxw_rd0RF8K7fcj_0,29849 +numpy/linalg/linalg.py,sha256=kDVK1GBxbUjlRgxXCoEfkRJm8yrNr1Iu7hMn2rKK8RE,90923 +numpy/linalg/linalg.pyi,sha256=zD9U5BUCB1uQggSxfZaTGX_uB2Hkp75sttGmZbCGgBI,7505 +numpy/linalg/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +numpy/linalg/tests/__pycache__/__init__.cpython-312.pyc,, +numpy/linalg/tests/__pycache__/test_deprecations.cpython-312.pyc,, +numpy/linalg/tests/__pycache__/test_linalg.cpython-312.pyc,, +numpy/linalg/tests/__pycache__/test_regression.cpython-312.pyc,, +numpy/linalg/tests/test_deprecations.py,sha256=9p_SRmtxj2zc1doY9Ie3dyy5JzWy-tCQWFoajcAJUmM,640 +numpy/linalg/tests/test_linalg.py,sha256=rgvmK6Or70u8mN04puetL3FgSxZ8fJrOlI5ptTgCU5k,78085 +numpy/linalg/tests/test_regression.py,sha256=qbugUmrENybkEaM1GhfA01RXQUy8AkzalbrfzSIgUmM,5434 +numpy/ma/API_CHANGES.txt,sha256=F_4jW8X5cYBbzpcwteymkonTmvzgKKY2kGrHF1AtnrI,3405 +numpy/ma/LICENSE,sha256=BfO4g1GYjs-tEKvpLAxQ5YdcZFLVAJoAhMwpFVH_zKY,1593 +numpy/ma/README.rst,sha256=q-gCsZ4Cw_gUGGvEjog556sJUHIm8WTAwkFK5Qnz9XA,9872 +numpy/ma/__init__.py,sha256=dgP0WdnOpph28Fd6UiqoyDKhfrct0H6QWqbCcETsk6M,1404 +numpy/ma/__init__.pyi,sha256=ppCg_TS0POutNB3moJE4kBabWURnc0WGXyYPquXZxS4,6063 +numpy/ma/__pycache__/__init__.cpython-312.pyc,, +numpy/ma/__pycache__/core.cpython-312.pyc,, +numpy/ma/__pycache__/extras.cpython-312.pyc,, +numpy/ma/__pycache__/mrecords.cpython-312.pyc,, +numpy/ma/__pycache__/setup.cpython-312.pyc,, +numpy/ma/__pycache__/testutils.cpython-312.pyc,, +numpy/ma/__pycache__/timer_comparison.cpython-312.pyc,, +numpy/ma/core.py,sha256=4MglVRJtmQ9_iIVaQ2b-_Vmw1TjAhEsMJdtKOhyBFXQ,278213 +numpy/ma/core.pyi,sha256=YfgyuBuKxZ5v4I2JxZDvCLhnztOCRgzTeDg-JGTon_M,14305 +numpy/ma/extras.py,sha256=MC7QPS34PC4wxNbOp7pTy57dqF9B-L6L1KMI6rrfe2w,64383 +numpy/ma/extras.pyi,sha256=BBsiCZbaPpGCY506fkmqZdBkJNCXcglc3wcSBuAACNk,2646 +numpy/ma/mrecords.py,sha256=degd6dLaDEvEWNHmvSnUZXos1csIzaqjR_jAutm8JfI,27232 +numpy/ma/mrecords.pyi,sha256=r1a2I662ywnhGS6zvfcyK-9RHVvb4sHxiCx9Dhf5AE4,1934 +numpy/ma/setup.py,sha256=MqmMicr_xHkAGoG-T7NJ4YdUZIJLO4ZFp6AmEJDlyhw,418 +numpy/ma/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +numpy/ma/tests/__pycache__/__init__.cpython-312.pyc,, +numpy/ma/tests/__pycache__/test_core.cpython-312.pyc,, +numpy/ma/tests/__pycache__/test_deprecations.cpython-312.pyc,, +numpy/ma/tests/__pycache__/test_extras.cpython-312.pyc,, +numpy/ma/tests/__pycache__/test_mrecords.cpython-312.pyc,, +numpy/ma/tests/__pycache__/test_old_ma.cpython-312.pyc,, +numpy/ma/tests/__pycache__/test_regression.cpython-312.pyc,, +numpy/ma/tests/__pycache__/test_subclassing.cpython-312.pyc,, +numpy/ma/tests/test_core.py,sha256=xd5S3oa0jObo8jnsJk0-o46d-KNC3RtgNRKinJeY_kE,215100 +numpy/ma/tests/test_deprecations.py,sha256=nq_wFVt2EBHcT3AHxattfKXx2JDf1K5D-QBzUU0_15A,2566 +numpy/ma/tests/test_extras.py,sha256=lX4cbdGDEXaBHzA3q8hJxve4635XCJw4AP7FO7zhOfk,74858 +numpy/ma/tests/test_mrecords.py,sha256=PsJhUlABgdpSsPUeijonfyFNqz5AfNSGQTtJUte7yts,19890 +numpy/ma/tests/test_old_ma.py,sha256=h4BncexBcBigqvZMA6RjDjpHPurWtt99A7KTag2rmOs,32690 +numpy/ma/tests/test_regression.py,sha256=foMpI0luAvwkkRpAfPDV_810h1URISXDZhmaNhxb50k,3287 +numpy/ma/tests/test_subclassing.py,sha256=HeTIE_n1I8atwzF8tpvNtGHp-0dmM8PT8AS4IDWbcso,16967 +numpy/ma/testutils.py,sha256=RQw0RyS7hOSVTk4KrCGleq0VHlnDqzwwaLtuZbRE4_I,10235 +numpy/ma/timer_comparison.py,sha256=pIGSZG-qYYYlRWSTgzPlyCAINbGKhXrZrDZBBjiM080,15658 +numpy/matlib.py,sha256=-54vTuGIgeTMg9ZUmElRPZ4Hr-XZ-om9xLzAsSoTvnc,10465 +numpy/matrixlib/__init__.py,sha256=BHBpQKoQv4EjT0UpWBA-Ck4L5OsMqTI2IuY24p-ucXk,242 +numpy/matrixlib/__init__.pyi,sha256=-t3ZuvbzRuRwWfZOeN4xlNWdm7gQEprhUsWzu8MRvUE,252 +numpy/matrixlib/__pycache__/__init__.cpython-312.pyc,, +numpy/matrixlib/__pycache__/defmatrix.cpython-312.pyc,, +numpy/matrixlib/__pycache__/setup.cpython-312.pyc,, +numpy/matrixlib/defmatrix.py,sha256=JXdJGm1LayOOXfKpp7OVZfb0pzzP4Lwh45sTJrleALc,30656 +numpy/matrixlib/defmatrix.pyi,sha256=lmBMRahKcMOl2PHDo79J67VRAZOkI54BzfDaTLpE0LI,451 +numpy/matrixlib/setup.py,sha256=1r7JRkSM4HyVorgtjoKJGWLcOcPO3wmvivpeEsVtAEg,426 +numpy/matrixlib/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +numpy/matrixlib/tests/__pycache__/__init__.cpython-312.pyc,, +numpy/matrixlib/tests/__pycache__/test_defmatrix.cpython-312.pyc,, +numpy/matrixlib/tests/__pycache__/test_interaction.cpython-312.pyc,, +numpy/matrixlib/tests/__pycache__/test_masked_matrix.cpython-312.pyc,, +numpy/matrixlib/tests/__pycache__/test_matrix_linalg.cpython-312.pyc,, +numpy/matrixlib/tests/__pycache__/test_multiarray.cpython-312.pyc,, +numpy/matrixlib/tests/__pycache__/test_numeric.cpython-312.pyc,, +numpy/matrixlib/tests/__pycache__/test_regression.cpython-312.pyc,, +numpy/matrixlib/tests/test_defmatrix.py,sha256=8E_-y7VD2vsq1y8CcI8km37pp5qcAtkciO16xqf2UIs,14982 +numpy/matrixlib/tests/test_interaction.py,sha256=PpjmgjEKighDXvt38labKE6L7f2jP74UEmp3JRb_iOY,11875 +numpy/matrixlib/tests/test_masked_matrix.py,sha256=7YO_LCO8DOhW3CuXJuxH93rnmttfvHnU7El-MBzxzFw,8932 +numpy/matrixlib/tests/test_matrix_linalg.py,sha256=ObbSUXU4R2pWajH__xAdizADrU2kBKDDCxkDV-oVBXc,2059 +numpy/matrixlib/tests/test_multiarray.py,sha256=jB3XCBmAtcqf-Wb9PwBW6uIykPpMPthuXLJ0giTKzZE,554 +numpy/matrixlib/tests/test_numeric.py,sha256=MP70qUwgshTtThKZaZDp7_6U-Z66NIV1geVhasGXejQ,441 +numpy/matrixlib/tests/test_regression.py,sha256=8sHDtO8Zi8p3a1eQKEWxtCmKrXmHoD3qxlIokg2AIAU,927 +numpy/polynomial/__init__.py,sha256=braLh6zP2QwuNKRKAaZGdC_qKWZ-tJlc3BN83LeuE_0,6781 +numpy/polynomial/__init__.pyi,sha256=W8szYtVUy0RUi83jmFLK58BN8CKVSoHA2CW7IcdUl1c,701 +numpy/polynomial/__pycache__/__init__.cpython-312.pyc,, +numpy/polynomial/__pycache__/_polybase.cpython-312.pyc,, +numpy/polynomial/__pycache__/chebyshev.cpython-312.pyc,, +numpy/polynomial/__pycache__/hermite.cpython-312.pyc,, +numpy/polynomial/__pycache__/hermite_e.cpython-312.pyc,, +numpy/polynomial/__pycache__/laguerre.cpython-312.pyc,, +numpy/polynomial/__pycache__/legendre.cpython-312.pyc,, +numpy/polynomial/__pycache__/polynomial.cpython-312.pyc,, +numpy/polynomial/__pycache__/polyutils.cpython-312.pyc,, +numpy/polynomial/__pycache__/setup.cpython-312.pyc,, +numpy/polynomial/_polybase.py,sha256=YEnnQwlTgbn3dyD89ueraUx5nxx3x_pH6K6mmyEmhi8,39271 +numpy/polynomial/_polybase.pyi,sha256=J7yU9PPZW4W8mkqAltDfnL4ZNwljuM-bDEj4DPTJZpY,2321 +numpy/polynomial/chebyshev.py,sha256=NZCKjIblcX99foqZyp51i0_r8p0r1VKVGZFmQ1__kEk,62796 +numpy/polynomial/chebyshev.pyi,sha256=035CNdOas4dnb6lFLzRiBrYT_VnWh2T1-A3ibm_HYkI,1387 +numpy/polynomial/hermite.py,sha256=t5CFM-qE4tszYJiQZ301VcMn7IM67y2rUZPFPtnVRAc,52514 +numpy/polynomial/hermite.pyi,sha256=hdsvTULow8bIjnATudf0i6brpLHV7vbOoHzaMvbjMy0,1217 +numpy/polynomial/hermite_e.py,sha256=jRR3f8Oth8poV2Ix8c0eLEQR3UZary-2RupOrEAEUMY,52642 +numpy/polynomial/hermite_e.pyi,sha256=zV7msb9v9rV0iv_rnD3SjP-TGyc6pd3maCqiPCj3PbA,1238 +numpy/polynomial/laguerre.py,sha256=mcVw0ckWVX-kzJ1QIhdcuuxzPjuFmA3plQLkloQMOYM,50858 +numpy/polynomial/laguerre.pyi,sha256=Gxc9SLISNKMWrKdsVJ9fKFFFwfxxZzfF-Yc-2r__z5M,1178 +numpy/polynomial/legendre.py,sha256=wjtgFajmKEbYkSUk3vWSCveMHDP6UymK28bNUk4Ov0s,51550 +numpy/polynomial/legendre.pyi,sha256=9dmANwkxf7EbOHV3XQBPoaDtc56cCkf75Wo7FG9Zfj4,1178 +numpy/polynomial/polynomial.py,sha256=XsaZPHmLGJFqpJs7rPvO5E0loWQ1L3YHLIUybVu4dU8,49112 +numpy/polynomial/polynomial.pyi,sha256=bOPRnub4xXxsUwNGeiQLTT4PCfN1ysSrf6LBZIcAN2Y,1132 +numpy/polynomial/polyutils.py,sha256=Xy5qjdrjnRaqSlClG1ROmwWccLkAPC7IcHaNJLvhCf4,23237 +numpy/polynomial/polyutils.pyi,sha256=cFAyZ9Xzuw8Huhn9FEz4bhyD00m2Dp-2DiUSyogJwSo,264 +numpy/polynomial/setup.py,sha256=dXQfzVUMP9OcB6iKv5yo1GLEwFB3gJ48phIgo4N-eM0,373 +numpy/polynomial/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +numpy/polynomial/tests/__pycache__/__init__.cpython-312.pyc,, +numpy/polynomial/tests/__pycache__/test_chebyshev.cpython-312.pyc,, +numpy/polynomial/tests/__pycache__/test_classes.cpython-312.pyc,, +numpy/polynomial/tests/__pycache__/test_hermite.cpython-312.pyc,, +numpy/polynomial/tests/__pycache__/test_hermite_e.cpython-312.pyc,, +numpy/polynomial/tests/__pycache__/test_laguerre.cpython-312.pyc,, +numpy/polynomial/tests/__pycache__/test_legendre.cpython-312.pyc,, +numpy/polynomial/tests/__pycache__/test_polynomial.cpython-312.pyc,, +numpy/polynomial/tests/__pycache__/test_polyutils.cpython-312.pyc,, +numpy/polynomial/tests/__pycache__/test_printing.cpython-312.pyc,, +numpy/polynomial/tests/__pycache__/test_symbol.cpython-312.pyc,, +numpy/polynomial/tests/test_chebyshev.py,sha256=6tMsFP1h7K8Zf72mNOta6Tv52_fVTlXknseuffj080c,20522 +numpy/polynomial/tests/test_classes.py,sha256=DFyY2IQBj3r2GZkvbRIeZO2EEY466xbuwc4PShAl4Sw,18331 +numpy/polynomial/tests/test_hermite.py,sha256=N9b2dx2UWPyja5v02dSoWYPnKvb6H-Ozgtrx-xjWz2k,18577 +numpy/polynomial/tests/test_hermite_e.py,sha256=_A3ohAWS4HXrQG06S8L47dImdZGTwYosCXnoyw7L45o,18911 +numpy/polynomial/tests/test_laguerre.py,sha256=BZOgs49VBXOFBepHopxuEDkIROHEvFBfWe4X73UZhn8,17511 +numpy/polynomial/tests/test_legendre.py,sha256=b_bblHs0F_BWw9ESuSq52ZsLKcQKFR5eqPf_SppWFqo,18673 +numpy/polynomial/tests/test_polynomial.py,sha256=4cuO8-5wdIxcz5CrucB5Ix7ySuMROokUF12F7ogQ_hc,20529 +numpy/polynomial/tests/test_polyutils.py,sha256=IxkbVfpcBqe5lOZluHFUPbLATLu1rwVg7ghLASpfYrY,3579 +numpy/polynomial/tests/test_printing.py,sha256=rfP4MaQbjGcO52faHmYrgsaarkm3Ndi3onwr6DDuapE,20525 +numpy/polynomial/tests/test_symbol.py,sha256=msTPv7B1niaKujU33kuZmdxJvLYvOjfl1oykmlL0dXo,5371 +numpy/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +numpy/random/LICENSE.md,sha256=EDFmtiuARDr7nrNIjgUuoGvgz_VmuQjxmeVh_eSa8Z8,3511 +numpy/random/__init__.pxd,sha256=9JbnX540aJNSothGs-7e23ozhilG6U8tINOUEp08M_k,431 +numpy/random/__init__.py,sha256=81Thnexg5umN5WZwD5TRyzNc2Yp-d14B6UC7NBgVKh8,7506 +numpy/random/__init__.pyi,sha256=RfW8mco48UaWDL1UC5ROv9vXiFZ9EGho62avhgEAHPc,2143 +numpy/random/__pycache__/__init__.cpython-312.pyc,, +numpy/random/__pycache__/_pickle.cpython-312.pyc,, +numpy/random/_bounded_integers.cpython-312-x86_64-linux-gnu.so,sha256=s59-K0zP1pBK5g_hUX9r2ovng1tb9p1U3sDWk8Xot5M,348704 +numpy/random/_bounded_integers.pxd,sha256=hcoucPH5hkFEM2nm12zYO-5O_Rt8RujEXT5YWuAzl1Q,1669 +numpy/random/_common.cpython-312-x86_64-linux-gnu.so,sha256=q9iMqPRH8ixPUfImc000cylmuuYe3SqiX3S_7JVL7ig,258888 +numpy/random/_common.pxd,sha256=s2_IdIQ0MhNbogamulvXe-b93wbx882onmYkxqswwpo,4939 +numpy/random/_examples/cffi/__pycache__/extending.cpython-312.pyc,, +numpy/random/_examples/cffi/__pycache__/parse.cpython-312.pyc,, +numpy/random/_examples/cffi/extending.py,sha256=xSla3zWqxi6Hj48EvnYfD3WHfE189VvC4XsKu4_T_Iw,880 +numpy/random/_examples/cffi/parse.py,sha256=Bnb7t_6S_c5-3dZrQ-XX9EazOKhftUfcCejXXWyd1EU,1771 +numpy/random/_examples/cython/extending.pyx,sha256=4IE692pq1V53UhPZqQiQGcIHXDoNyqTx62x5a36puVg,2290 +numpy/random/_examples/cython/extending_distributions.pyx,sha256=oazFVWeemfE0eDzax7r7MMHNL1_Yofws2m-c_KT2Hbo,3870 +numpy/random/_examples/cython/meson.build,sha256=rXtugURMEo-ef4bPE1QIv4mzvWbeGjmcTdKCBvjxjtw,1443 +numpy/random/_examples/numba/__pycache__/extending.cpython-312.pyc,, +numpy/random/_examples/numba/__pycache__/extending_distributions.cpython-312.pyc,, +numpy/random/_examples/numba/extending.py,sha256=Ipyzel_h5iU_DMJ_vnXUgQC38uMDMn7adUpWSeEQLFE,1957 +numpy/random/_examples/numba/extending_distributions.py,sha256=Jnr9aWkHyIWygNbdae32GVURK-5T9BTGhuExRpvve98,2034 +numpy/random/_generator.cpython-312-x86_64-linux-gnu.so,sha256=Wz7yrIt4qoO8hptw4w4qcPvTqzc8UlPtbrqZgqVf1-I,946872 +numpy/random/_generator.pyi,sha256=zRvo_y6g0pWkE4fO1M9jLYUkxDfGdA6Enreb3U2AADM,22442 +numpy/random/_mt19937.cpython-312-x86_64-linux-gnu.so,sha256=Nhn3-Rue5xl8KQLA4Zfmmy5d1F-xHNIuVy6bC4hlFKk,119488 +numpy/random/_mt19937.pyi,sha256=_iZKaAmuKBQ4itSggfQvYYj_KjktcN4rt-YpE6bqFAM,724 +numpy/random/_pcg64.cpython-312-x86_64-linux-gnu.so,sha256=V3wUaPT7QLsjGEND4sG2RaF9HUk2QeqSwFLyhtxutVY,125040 +numpy/random/_pcg64.pyi,sha256=uxr5CbEJetN6lv9vBG21jlRhuzOK8SQnXrwqAQBxj_c,1091 +numpy/random/_philox.cpython-312-x86_64-linux-gnu.so,sha256=LJsf5T7xGePtKstzyALPKZZQKw_VHUkm1AR1ds6ldRQ,106712 +numpy/random/_philox.pyi,sha256=OKlaiIU-hj72Bp04zjNifwusOD_3-mYxIfvyuys8c_o,978 +numpy/random/_pickle.py,sha256=4NhdT-yk7C0m3tyZWmouYAs3ZGNPdPVNGfUIyuh8HDY,2318 +numpy/random/_sfc64.cpython-312-x86_64-linux-gnu.so,sha256=WIMwLOM6_VTbZjGtv14AApe460LA7IlLvsMYteaxQmg,76224 +numpy/random/_sfc64.pyi,sha256=09afHTedVW-519493ZXtGcl-H-_zluj-B_yfEJG8MMs,709 +numpy/random/bit_generator.cpython-312-x86_64-linux-gnu.so,sha256=h8XHMIh5Q8YDsWxTzyEzmDNu5BDvRllVGAk5d6_VsMs,234016 +numpy/random/bit_generator.pxd,sha256=lArpIXSgTwVnJMYc4XX0NGxegXq3h_QsUDK6qeZKbNc,1007 +numpy/random/bit_generator.pyi,sha256=aXv7a_hwa0nkjY8P2YENslwWp89UcFRn09woXh7Uoc0,3510 +numpy/random/c_distributions.pxd,sha256=7DE-mV3H_Dihk4OK4gMHHkyD4tPX1cAi4570zi5CI30,6344 +numpy/random/lib/libnpyrandom.a,sha256=xUcvOvieju5PThPQ8q0-uGJ5fjsCd5umnjIerIc85Sg,71926 +numpy/random/mtrand.cpython-312-x86_64-linux-gnu.so,sha256=jYZrS2EHQBq5VGBEVkII4KWJnDkD2gknfRxIddmLzw8,749040 +numpy/random/mtrand.pyi,sha256=3vAGOXsvyFFv0yZl34pVVPP7Dgt22COyfn4tUoi_hEQ,19753 +numpy/random/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +numpy/random/tests/__pycache__/__init__.cpython-312.pyc,, +numpy/random/tests/__pycache__/test_direct.cpython-312.pyc,, +numpy/random/tests/__pycache__/test_extending.cpython-312.pyc,, +numpy/random/tests/__pycache__/test_generator_mt19937.cpython-312.pyc,, +numpy/random/tests/__pycache__/test_generator_mt19937_regressions.cpython-312.pyc,, +numpy/random/tests/__pycache__/test_random.cpython-312.pyc,, +numpy/random/tests/__pycache__/test_randomstate.cpython-312.pyc,, +numpy/random/tests/__pycache__/test_randomstate_regression.cpython-312.pyc,, +numpy/random/tests/__pycache__/test_regression.cpython-312.pyc,, +numpy/random/tests/__pycache__/test_seed_sequence.cpython-312.pyc,, +numpy/random/tests/__pycache__/test_smoke.cpython-312.pyc,, +numpy/random/tests/data/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +numpy/random/tests/data/__pycache__/__init__.cpython-312.pyc,, +numpy/random/tests/data/mt19937-testset-1.csv,sha256=Xkef402AVB-eZgYQkVtoxERHkxffCA9Jyt_oMbtJGwY,15844 +numpy/random/tests/data/mt19937-testset-2.csv,sha256=nsBEQNnff-aFjHYK4thjvUK4xSXDSfv5aTbcE59pOkE,15825 +numpy/random/tests/data/pcg64-testset-1.csv,sha256=xB00DpknGUTTCxDr9L6aNo9Hs-sfzEMbUSS4t11TTfE,23839 +numpy/random/tests/data/pcg64-testset-2.csv,sha256=NTdzTKvG2U7_WyU_IoQUtMzU3kEvDH39CgnR6VzhTkw,23845 +numpy/random/tests/data/pcg64dxsm-testset-1.csv,sha256=vNSUT-gXS_oEw_awR3O30ziVO4seNPUv1UIZ01SfVnI,23833 +numpy/random/tests/data/pcg64dxsm-testset-2.csv,sha256=uylS8PU2AIKZ185OC04RBr_OePweGRtvn-dE4YN0yYA,23839 +numpy/random/tests/data/philox-testset-1.csv,sha256=SedRaIy5zFadmk71nKrGxCFZ6BwKz8g1A9-OZp3IkkY,23852 +numpy/random/tests/data/philox-testset-2.csv,sha256=dWECt-sbfvaSiK8-Ygp5AqyjoN5i26VEOrXqg01rk3g,23838 +numpy/random/tests/data/sfc64-testset-1.csv,sha256=iHs6iX6KR8bxGwKk-3tedAdMPz6ZW8slDSUECkAqC8Q,23840 +numpy/random/tests/data/sfc64-testset-2.csv,sha256=FIDIDFCaPZfWUSxsJMAe58hPNmMrU27kCd9FhCEYt_k,23833 +numpy/random/tests/test_direct.py,sha256=6vLpCyeKnAWFEZei7l2YihVLQ0rSewO1hJBWt7A5fyQ,17779 +numpy/random/tests/test_extending.py,sha256=S3Wrzu3di4uBhr-Pxnx5dOPvlBY0FRdZqVX6CC1IN6s,4038 +numpy/random/tests/test_generator_mt19937.py,sha256=35LBwV6TtWPnxhefutxTQmhLzAQ5Ee4YiY8ziDXM-eQ,115477 +numpy/random/tests/test_generator_mt19937_regressions.py,sha256=xGkdz76BMX1EK0QPfabVxpNx9qQ9OC-1ZStWOs6N_M8,6387 +numpy/random/tests/test_random.py,sha256=kEkQs3i7zcpm9MozIRIz1FIx5B6fmXk0QqX0l6l-u_Y,70087 +numpy/random/tests/test_randomstate.py,sha256=DxF7rMUSxaAlL4h1qC3onHcHR7T_6rKWPbr0nJH84nE,85031 +numpy/random/tests/test_randomstate_regression.py,sha256=VucYWIjA7sAquWsalvZMnfkmYLM1O6ysyWnLl931-lA,7917 +numpy/random/tests/test_regression.py,sha256=trntK51UvajOVELiluEO85l64CKSw5nvBSc5SqYyr9w,5439 +numpy/random/tests/test_seed_sequence.py,sha256=GNRJ4jyzrtfolOND3gUWamnbvK6-b_p1bBK_RIG0sfU,3311 +numpy/random/tests/test_smoke.py,sha256=jjNz0aEGD1_oQl9a9UWt6Mz_298alG7KryLT1pgHljw,28183 +numpy/testing/__init__.py,sha256=InpVKoDAzMKO_l_HNcatziW_u1k9_JZze__t2nybrL0,595 +numpy/testing/__init__.pyi,sha256=AhK5NuOpdD-JjIzXOlssE8_iSLyFAAHzyGV_w1BT7vA,1674 +numpy/testing/__pycache__/__init__.cpython-312.pyc,, +numpy/testing/__pycache__/overrides.cpython-312.pyc,, +numpy/testing/__pycache__/print_coercion_tables.cpython-312.pyc,, +numpy/testing/__pycache__/setup.cpython-312.pyc,, +numpy/testing/_private/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +numpy/testing/_private/__pycache__/__init__.cpython-312.pyc,, +numpy/testing/_private/__pycache__/extbuild.cpython-312.pyc,, +numpy/testing/_private/__pycache__/utils.cpython-312.pyc,, +numpy/testing/_private/extbuild.py,sha256=nG2dwP4nUmQS3e5eIRinxt0s_f4sxxA1YfohCg-navo,8017 +numpy/testing/_private/utils.py,sha256=3FrSTMi0OdpDODBDoncgiDQzdo5NKA6YVfQ3uKRSQnc,85242 +numpy/testing/_private/utils.pyi,sha256=MMNrvwEeSTYzZFWawSSzHnTFYG-cSAIiID-1FuJ1f8U,10123 +numpy/testing/overrides.py,sha256=u6fcKSBC8HIzMPWKAbdyowU71h2Fx2ekDQxpG5NhIr8,2123 +numpy/testing/print_coercion_tables.py,sha256=ndxOsS4XfrZ4UY_9nqRTCnxhkzgdqcuUHL8nezd7Op4,6180 +numpy/testing/setup.py,sha256=GPKAtTTBRsNW4kmR7NjP6mmBR_GTdpaTvkTm10_VcLg,709 +numpy/testing/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +numpy/testing/tests/__pycache__/__init__.cpython-312.pyc,, +numpy/testing/tests/__pycache__/test_utils.cpython-312.pyc,, +numpy/testing/tests/test_utils.py,sha256=IDOr-GXuNGlrsb-XzGSYUHXEqcGYJ78p60jOpBqyPM4,55740 +numpy/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +numpy/tests/__pycache__/__init__.cpython-312.pyc,, +numpy/tests/__pycache__/test__all__.cpython-312.pyc,, +numpy/tests/__pycache__/test_ctypeslib.cpython-312.pyc,, +numpy/tests/__pycache__/test_lazyloading.cpython-312.pyc,, +numpy/tests/__pycache__/test_matlib.cpython-312.pyc,, +numpy/tests/__pycache__/test_numpy_config.cpython-312.pyc,, +numpy/tests/__pycache__/test_numpy_version.cpython-312.pyc,, +numpy/tests/__pycache__/test_public_api.cpython-312.pyc,, +numpy/tests/__pycache__/test_reloading.cpython-312.pyc,, +numpy/tests/__pycache__/test_scripts.cpython-312.pyc,, +numpy/tests/__pycache__/test_warnings.cpython-312.pyc,, +numpy/tests/test__all__.py,sha256=L3mCnYPTpzAgNfedVuq9g7xPWbc0c1Pot94k9jZ9NpI,221 +numpy/tests/test_ctypeslib.py,sha256=B06QKeFRgDIEbkEPBy_zYA1H5E2exuhTi7IDkzV8gfo,12257 +numpy/tests/test_lazyloading.py,sha256=YETrYiDLAqLX04K_u5_3NVxAfxDoeguxwkIRfz6qKcY,1162 +numpy/tests/test_matlib.py,sha256=gwhIXrJJo9DiecaGLCHLJBjhx2nVGl6yHq80AOUQSRM,1852 +numpy/tests/test_numpy_config.py,sha256=qHvepgi9oyAbQuZD06k7hpcCC2MYhdzcY6D1iQDPNMI,1241 +numpy/tests/test_numpy_version.py,sha256=A8cXFzp4k-p6J5zkOxlDfDvkoFMxDW2hpTFVXcaQRVo,1479 +numpy/tests/test_public_api.py,sha256=DTq7SO84uBjC2tKPoqX17xazc-SLkTAbQ2fLZwGM2jc,18170 +numpy/tests/test_reloading.py,sha256=QuVaPQulcNLg4Fl31Lw-O89L42KclYCK68n5GVy0PNQ,2354 +numpy/tests/test_scripts.py,sha256=jluCLfG94VM1cuX-5RcLFBli_yaJZpIvmVuMxRKRJrc,1645 +numpy/tests/test_warnings.py,sha256=ZEtXqHI1iyeVeLfVxDcMfN5qw67Ti2u54709hvBG4eY,2284 +numpy/typing/__init__.py,sha256=VoTILNDrUWvZx0LK9_97lBLQFKtSGmDt4QLOH8zYvlo,5234 +numpy/typing/__pycache__/__init__.cpython-312.pyc,, +numpy/typing/__pycache__/mypy_plugin.cpython-312.pyc,, +numpy/typing/__pycache__/setup.cpython-312.pyc,, +numpy/typing/mypy_plugin.py,sha256=24zVk4Ei3qH4Hc3SSz3v0XtIsycTo8HKoY6ilhB_7AQ,6376 +numpy/typing/setup.py,sha256=Cnz9q53w-vJNyE6vYxqYvQXx0pJbrG9quHyz9sqxfek,374 +numpy/typing/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +numpy/typing/tests/__pycache__/__init__.cpython-312.pyc,, +numpy/typing/tests/__pycache__/test_isfile.cpython-312.pyc,, +numpy/typing/tests/__pycache__/test_runtime.cpython-312.pyc,, +numpy/typing/tests/__pycache__/test_typing.cpython-312.pyc,, +numpy/typing/tests/data/fail/arithmetic.pyi,sha256=4rY_ASCERAl8WCus1RakOe0Aw-8vvjilL29mgdD4lv0,3850 +numpy/typing/tests/data/fail/array_constructors.pyi,sha256=X9y_jUYS17WfYmXW5NwkVudyiR6ouUaAwEh0JRte42o,1089 +numpy/typing/tests/data/fail/array_like.pyi,sha256=OVAlEJZ5k8ZRKt0aGpZQwIjlUGpy0PzOOYqfI-IMqBQ,455 +numpy/typing/tests/data/fail/array_pad.pyi,sha256=57oK0Yp53rtKjjIrRFYLcxa-IfIGhtI-bEem7ggJKwI,132 +numpy/typing/tests/data/fail/arrayprint.pyi,sha256=-Fs9VnQfxyfak008Hq8kJWfB0snA6jGDXZz8ljQnwGE,549 +numpy/typing/tests/data/fail/arrayterator.pyi,sha256=FoU4ahHkJZ67dwWXer5FXLjjjesKKg-w2Jq1X1bHymA,480 +numpy/typing/tests/data/fail/bitwise_ops.pyi,sha256=GN9dVqk4_HFXn7zbRrHzJq_UGRFBccoYVUG1UuE7bXs,515 +numpy/typing/tests/data/fail/char.pyi,sha256=-vgN6EmfQ8VaA4SOZ5Ol9u4-Z7Q5I7G78LmaxZOuZ90,2615 +numpy/typing/tests/data/fail/chararray.pyi,sha256=jrNryZFpr8nxG2IHb9e0x3ranpvJpBy_RDex-WpT5rU,2296 +numpy/typing/tests/data/fail/comparisons.pyi,sha256=U4neWzwwtxG6QXsKlNGJuKXHBtwzYBQOa47_7SKF5Wg,888 +numpy/typing/tests/data/fail/constants.pyi,sha256=YSqNbXdhbdMmYbs7ntH0FCKbnm8IFeqsDlZBqcU43iw,286 +numpy/typing/tests/data/fail/datasource.pyi,sha256=PRT2hixR-mVxr2UILvHa99Dr54EF2h3snJXE-v3rWcc,395 +numpy/typing/tests/data/fail/dtype.pyi,sha256=OAGABqdXNB8gClJFEGMckoycuZcIasMaAlS2RkiKROI,334 +numpy/typing/tests/data/fail/einsumfunc.pyi,sha256=RS7GZqUCT_vEFJoyUx4gZlPO8GNFFNFWidxl-wLyRv0,539 +numpy/typing/tests/data/fail/false_positives.pyi,sha256=Q61qMsSsNCtmO0EMRxHj5Z7RYTyrELVpkzfJY5eK8Z0,366 +numpy/typing/tests/data/fail/flatiter.pyi,sha256=qLM4qm7gvJtEZ0rTHcyasUzoP5JbX4FREtqV3g1w6Lo,843 +numpy/typing/tests/data/fail/fromnumeric.pyi,sha256=FH2mjkgtCbA9soqlJRhYN7IIfRRrUL1i9mwqcbYKZSc,5591 +numpy/typing/tests/data/fail/histograms.pyi,sha256=yAPVt0rYTwtxnigoGT-u7hhKCE9iYxsXc24x2HGBrmA,367 +numpy/typing/tests/data/fail/index_tricks.pyi,sha256=moINir9iQoi6Q1ZuVg5BuSB9hSBtbg_uzv-Qm_lLYZk,509 +numpy/typing/tests/data/fail/lib_function_base.pyi,sha256=6y9T773CBLX-jUry1sCQGVuKVKM2wMuQ56Ni5V5j4Dw,2081 +numpy/typing/tests/data/fail/lib_polynomial.pyi,sha256=Ur7Y4iZX6WmoH5SDm0ePi8C8LPsuPs2Yr7g7P5O613g,899 +numpy/typing/tests/data/fail/lib_utils.pyi,sha256=VFpE6_DisvlDByyp1PiNPJEe5IcZp8cH0FlAJyoZipo,276 +numpy/typing/tests/data/fail/lib_version.pyi,sha256=7-ZJDZwDcB-wzpMN8TeYtZAgaqc7xnQ8Dnx2ISiX2Ts,158 +numpy/typing/tests/data/fail/linalg.pyi,sha256=yDd05aK1dI37RPt3pD2eJYo4dZFaT2yB1PEu3K0y9Tg,1322 +numpy/typing/tests/data/fail/memmap.pyi,sha256=HSTCQYNuW1Y6X1Woj361pN4rusSPs4oDCXywqk20yUo,159 +numpy/typing/tests/data/fail/modules.pyi,sha256=_ek4zKcdP-sIh_f-IDY0tP-RbLORKCSWelM9AOYxsyA,670 +numpy/typing/tests/data/fail/multiarray.pyi,sha256=XCdBxufNhR8ZtG8UMzk8nt9_NC5gJTKP9-xTqKO_K9I,1693 +numpy/typing/tests/data/fail/ndarray.pyi,sha256=YnjXy16RHs_esKelMjB07865CQ7gLyQnXhnitq5Kv5c,405 +numpy/typing/tests/data/fail/ndarray_misc.pyi,sha256=w-10xTDDWoff9Lq0dBO-jBeiBR-XjCz2qmes0dLx238,1372 +numpy/typing/tests/data/fail/nditer.pyi,sha256=w7emjnOxnf3NcvLktNLlke6Cuivn2gU3sVmGCfbG6rw,325 +numpy/typing/tests/data/fail/nested_sequence.pyi,sha256=em4GZwLDFE0QSxxg081wVwhh-Dmtkn8f7wThI0DiXVs,427 +numpy/typing/tests/data/fail/npyio.pyi,sha256=56QuHo9SvVR3Uhzl6gQZncCpX575Gy5wugjMICh20m0,620 +numpy/typing/tests/data/fail/numerictypes.pyi,sha256=fevH9x80CafYkiyBJ7LMLVl6GyTvQrZ34trBu6O8TtM,276 +numpy/typing/tests/data/fail/random.pyi,sha256=p5WsUGyOL-MGIeALh9Y0dVhYSRQLaUwMdjXc3G6C_7Q,2830 +numpy/typing/tests/data/fail/rec.pyi,sha256=Ws3TyesnoQjt7Q0wwtpShRDJmZCs2jjP17buFMomVGA,704 +numpy/typing/tests/data/fail/scalars.pyi,sha256=o91BwSfzPTczYVtbXsirqQUoUoYP1C_msGjc2GYsV04,2952 +numpy/typing/tests/data/fail/shape_base.pyi,sha256=Y_f4buHtX2Q2ZA4kaDTyR8LErlPXTzCB_-jBoScGh_Q,152 +numpy/typing/tests/data/fail/stride_tricks.pyi,sha256=IjA0Xrnx0lG3m07d1Hjbhtyo1Te5cXgjgr5fLUo4LYQ,315 +numpy/typing/tests/data/fail/testing.pyi,sha256=e7b5GKTWCtKGoB8z2a8edsW0Xjl1rMheALsvzEJjlCw,1370 +numpy/typing/tests/data/fail/twodim_base.pyi,sha256=ZqbRJfy5S_pW3fFLuomy4L5SBNqj6Nklexg9KDTo65c,899 +numpy/typing/tests/data/fail/type_check.pyi,sha256=CIyI0j0Buxv0QgCvNG2urjaKpoIZ-ZNawC2m6NzGlbo,379 +numpy/typing/tests/data/fail/ufunc_config.pyi,sha256=ukA0xwfJHLoGfoOIpWIN-91wj-DG8oaIjYbO72ymjg4,733 +numpy/typing/tests/data/fail/ufunclike.pyi,sha256=lbxjJyfARmt_QK1HxhxFxvwQTqCEZwJ9I53Wp8X3KIY,679 +numpy/typing/tests/data/fail/ufuncs.pyi,sha256=YaDTL7QLmGSUxE6JVMzpOlZTjHWrgbOo0UIlkX-6ZQk,1347 +numpy/typing/tests/data/fail/warnings_and_errors.pyi,sha256=PrbYDFI7IGN3Gf0OPBkVfefzQs4AXHwDQ495pvrX3RY,174 +numpy/typing/tests/data/misc/extended_precision.pyi,sha256=bS8bBeCFqjgtOiy-8_y39wfa7rwhdjLz2Vmo-RXAYD4,884 +numpy/typing/tests/data/mypy.ini,sha256=Ynv1VSx_kXTD2mFC3ZpgEFuCOg1F2VJXxPk0dxUnF2M,108 +numpy/typing/tests/data/pass/__pycache__/arithmetic.cpython-312.pyc,, +numpy/typing/tests/data/pass/__pycache__/array_constructors.cpython-312.pyc,, +numpy/typing/tests/data/pass/__pycache__/array_like.cpython-312.pyc,, +numpy/typing/tests/data/pass/__pycache__/arrayprint.cpython-312.pyc,, +numpy/typing/tests/data/pass/__pycache__/arrayterator.cpython-312.pyc,, +numpy/typing/tests/data/pass/__pycache__/bitwise_ops.cpython-312.pyc,, +numpy/typing/tests/data/pass/__pycache__/comparisons.cpython-312.pyc,, +numpy/typing/tests/data/pass/__pycache__/dtype.cpython-312.pyc,, +numpy/typing/tests/data/pass/__pycache__/einsumfunc.cpython-312.pyc,, +numpy/typing/tests/data/pass/__pycache__/flatiter.cpython-312.pyc,, +numpy/typing/tests/data/pass/__pycache__/fromnumeric.cpython-312.pyc,, +numpy/typing/tests/data/pass/__pycache__/index_tricks.cpython-312.pyc,, +numpy/typing/tests/data/pass/__pycache__/lib_utils.cpython-312.pyc,, +numpy/typing/tests/data/pass/__pycache__/lib_version.cpython-312.pyc,, +numpy/typing/tests/data/pass/__pycache__/literal.cpython-312.pyc,, +numpy/typing/tests/data/pass/__pycache__/mod.cpython-312.pyc,, +numpy/typing/tests/data/pass/__pycache__/modules.cpython-312.pyc,, +numpy/typing/tests/data/pass/__pycache__/multiarray.cpython-312.pyc,, +numpy/typing/tests/data/pass/__pycache__/ndarray_conversion.cpython-312.pyc,, +numpy/typing/tests/data/pass/__pycache__/ndarray_misc.cpython-312.pyc,, +numpy/typing/tests/data/pass/__pycache__/ndarray_shape_manipulation.cpython-312.pyc,, +numpy/typing/tests/data/pass/__pycache__/numeric.cpython-312.pyc,, +numpy/typing/tests/data/pass/__pycache__/numerictypes.cpython-312.pyc,, +numpy/typing/tests/data/pass/__pycache__/random.cpython-312.pyc,, +numpy/typing/tests/data/pass/__pycache__/scalars.cpython-312.pyc,, +numpy/typing/tests/data/pass/__pycache__/simple.cpython-312.pyc,, +numpy/typing/tests/data/pass/__pycache__/simple_py3.cpython-312.pyc,, +numpy/typing/tests/data/pass/__pycache__/ufunc_config.cpython-312.pyc,, +numpy/typing/tests/data/pass/__pycache__/ufunclike.cpython-312.pyc,, +numpy/typing/tests/data/pass/__pycache__/ufuncs.cpython-312.pyc,, +numpy/typing/tests/data/pass/__pycache__/warnings_and_errors.cpython-312.pyc,, +numpy/typing/tests/data/pass/arithmetic.py,sha256=2z3dmuysQQmiPz8x0bg8SOOKW62mVJn97uMa9T0L7Vk,7455 +numpy/typing/tests/data/pass/array_constructors.py,sha256=3GrhfBcmWX53pJHD0NvhXjwr2-uNKREbR1I9WCcZ7rI,2419 +numpy/typing/tests/data/pass/array_like.py,sha256=ce_IVubBd7J6FkSpJmD7qMlRLuwmiidhOqhYfZb16Wo,916 +numpy/typing/tests/data/pass/arrayprint.py,sha256=y_KkuLz1uM7pv53qfq7GQOuud4LoXE3apK1wtARdVyM,766 +numpy/typing/tests/data/pass/arrayterator.py,sha256=FqcpKdUQBQ0FazHFxr9MsLEZG-jnJVGKWZX2owRr4DQ,393 +numpy/typing/tests/data/pass/bitwise_ops.py,sha256=UnmxVr9HwI8ifdrutGm_u3EZU4iOOPQhrOku7hTaH0c,970 +numpy/typing/tests/data/pass/comparisons.py,sha256=nTE-fvraLK6xTZcP4uPV02wOShzYKWDaoapx35AeDOY,2992 +numpy/typing/tests/data/pass/dtype.py,sha256=MqDKC6Ywv6jNkWsR8rdLuabzHUco5w1OylDHEdxve_I,1069 +numpy/typing/tests/data/pass/einsumfunc.py,sha256=eXj5L5MWPtQHgrHPsJ36qqrmBHqct9UoujjJCvHnF1k,1370 +numpy/typing/tests/data/pass/flatiter.py,sha256=0BnbuLMBC7MQlprNZ0QhNSscfYwPhEhXOhWoyiRACWU,174 +numpy/typing/tests/data/pass/fromnumeric.py,sha256=Xd_nJVVDoONdztUX8ddgo7EXJ2FD8AX51MO_Yujnmog,3742 +numpy/typing/tests/data/pass/index_tricks.py,sha256=oaFD9vY01_RI5OkrXt-xTk1n_dd-SpuPp-eZ58XR3c8,1492 +numpy/typing/tests/data/pass/lib_utils.py,sha256=sDQCjHVGUwct0RQqAtH5_16y241siSY4bXKZRsuJ8xA,434 +numpy/typing/tests/data/pass/lib_version.py,sha256=HnuGOx7tQA_bcxFIJ3dRoMAR0fockxg4lGqQ4g7LGIw,299 +numpy/typing/tests/data/pass/literal.py,sha256=DLzdWHD6ttW4S0NEvGQbsH_UEJjhZyhvO4OXJjoyvZQ,1331 +numpy/typing/tests/data/pass/mod.py,sha256=HB9aK4_wGJbc44tomaoroNy0foIL5cI9KIjknvMTbkk,1578 +numpy/typing/tests/data/pass/modules.py,sha256=t0KJxYWbrWd7HbbgIDFb3LAhJBiNNb6QPjjFDAgC2mU,576 +numpy/typing/tests/data/pass/multiarray.py,sha256=MxHax6l94yqlTVZleAqG77ILEbW6wU5osPcHzxJ85ns,1331 +numpy/typing/tests/data/pass/ndarray_conversion.py,sha256=yPgzXG6paY1uF_z-QyHYrcmrZvhX7qtvTUh7ANLseCA,1626 +numpy/typing/tests/data/pass/ndarray_misc.py,sha256=z3mucbn9fLM1gxmbUhWlp2lcrOv4zFjqZFze0caE2EA,2715 +numpy/typing/tests/data/pass/ndarray_shape_manipulation.py,sha256=37eYwMNqMLwanIW9-63hrokacnSz2K_qtPUlkdpsTjo,640 +numpy/typing/tests/data/pass/numeric.py,sha256=SdnsD5zv0wm8T2hnIylyS14ig2McSz6rG9YslckbNQ4,1490 +numpy/typing/tests/data/pass/numerictypes.py,sha256=r0_s-a0-H2MdWIn4U4P6W9RQO0V1xrDusgodHNZeIYM,750 +numpy/typing/tests/data/pass/random.py,sha256=uJCnzlsOn9hr_G1TpHLdsweJI4EdhUSEQ4dxROPjqAs,61881 +numpy/typing/tests/data/pass/scalars.py,sha256=En0adCZAwEigZrzdQ0JQwDEmrS0b-DMd1vvjkFcvwo8,3479 +numpy/typing/tests/data/pass/simple.py,sha256=HmAfCOdZBWQF211YaZFrIGisMgu5FzTELApKny08n3Y,2676 +numpy/typing/tests/data/pass/simple_py3.py,sha256=HuLrc5aphThQkLjU2_19KgGFaXwKOfSzXe0p2xMm8ZI,96 +numpy/typing/tests/data/pass/ufunc_config.py,sha256=_M8v-QWAeT1-2MkfSeAbNl_ZwyPvYfPTsLl6c1X8d_w,1204 +numpy/typing/tests/data/pass/ufunclike.py,sha256=Gve6cJ2AT3TAwOjUOQQDIUnqsRCGYq70_tv_sgODiiA,1039 +numpy/typing/tests/data/pass/ufuncs.py,sha256=xGuKuqPetUTS4io5YDHaki5nbYRu-wC29SGU32tzVIg,462 +numpy/typing/tests/data/pass/warnings_and_errors.py,sha256=Pcg-QWfY4PAhTKyehae8q6LhtbUABxa2Ye63-3h1f4w,150 +numpy/typing/tests/data/reveal/arithmetic.pyi,sha256=Ndmi_IFAl8z28RHsYTbOouf-B5FH91x_9ky-JwsdXVg,19765 +numpy/typing/tests/data/reveal/array_constructors.pyi,sha256=DcT8Z2rEpqYfjXySBejk8cGOUidUmizZGE5ZEy7r14E,10600 +numpy/typing/tests/data/reveal/arraypad.pyi,sha256=Q1pcU4B3eRsw5jsv-S0MsEfNUbp_4aMdO_o3n0rtA2A,776 +numpy/typing/tests/data/reveal/arrayprint.pyi,sha256=YyzzkL-wj4Rs-fdo3brpoaWtb5g3yk4Vn2HKu5KRo4w,876 +numpy/typing/tests/data/reveal/arraysetops.pyi,sha256=ApCFQcZzQ08zV32SJ86Xyv_7jazl3XKMmJmULtNquJ8,4155 +numpy/typing/tests/data/reveal/arrayterator.pyi,sha256=TF_1eneHoT0v9HqS9dKc5Xiv3iY3E330GR1RNcJ7s2Q,1111 +numpy/typing/tests/data/reveal/bitwise_ops.pyi,sha256=nRkyUGrBB_Es7TKyDxS_s3u2dFgBfzjocInI9Ea-J10,3919 +numpy/typing/tests/data/reveal/char.pyi,sha256=M_iTa9Pn8F7jQ1k6RN9KvbhEn00g7UYJZ5PV57ikcZM,7289 +numpy/typing/tests/data/reveal/chararray.pyi,sha256=O0EfwnKc3W1Fnx1c7Yotb1O84kVMuqJLlMBXd2duvjI,6093 +numpy/typing/tests/data/reveal/comparisons.pyi,sha256=huaf-seaF5ndTqfoaBfPtMMkOYovq7ibJl5-CRoQW7s,7468 +numpy/typing/tests/data/reveal/constants.pyi,sha256=P9vFEMkPpJ5KeUnzqPOuyHlh3zAFl9lzB4WxyB2od7A,1949 +numpy/typing/tests/data/reveal/ctypeslib.pyi,sha256=-Pk2rLEGCzz3B_y8Mu10JSVA8gPFztl5fV1dspPzqig,4727 +numpy/typing/tests/data/reveal/datasource.pyi,sha256=e8wjn60tO5EdnkBF34JrZT5XvdyW7kRWD2abtgr6qUg,671 +numpy/typing/tests/data/reveal/dtype.pyi,sha256=TKrYyxMu5IGobs0SDTIRcPuWsZ5X7zMYB4pmUlTTJxA,2872 +numpy/typing/tests/data/reveal/einsumfunc.pyi,sha256=pbtSfzIWUJRkDpe2riHBlvFlNSC3CqVM-SbYtBgX9H0,2044 +numpy/typing/tests/data/reveal/emath.pyi,sha256=-muNpWOv_niIn-zS3gUnFO4qBZAouNlVGue2x1L5Ris,2423 +numpy/typing/tests/data/reveal/false_positives.pyi,sha256=AplTmZV7TS7nivU8vegbstMN5MdMv4U0JJdZ4IeeA5M,482 +numpy/typing/tests/data/reveal/fft.pyi,sha256=ReQ9qn5frvJEy-g0RWpUGlPBntUS1cFSIu6WfPotHzE,1749 +numpy/typing/tests/data/reveal/flatiter.pyi,sha256=e1OQsVxQpgyfqMNw2puUTATl-w3swvdknlctAiWxf_E,882 +numpy/typing/tests/data/reveal/fromnumeric.pyi,sha256=PNtGQR1VmGk_xNbd0eP7k7B2oNCMBz2XOJ17-_SdE5M,12101 +numpy/typing/tests/data/reveal/getlimits.pyi,sha256=nUGOMFpWj3pMgqLy6ZbR7A4G2q7iLIl5zEFBGf-Qcfw,1592 +numpy/typing/tests/data/reveal/histograms.pyi,sha256=MxKWoa7UoJRRLim53H6OoyYfz87P3_9YUXGYPTknGVQ,1303 +numpy/typing/tests/data/reveal/index_tricks.pyi,sha256=HpD7lU7hcyDoLdZbeqskPXnX7KYwPtll7uJKYUzrlE8,3177 +numpy/typing/tests/data/reveal/lib_function_base.pyi,sha256=eSiSZUlmPXqVPKknM7GcEv76BDgj0IJRu3FXcZXpmqc,8318 +numpy/typing/tests/data/reveal/lib_polynomial.pyi,sha256=TOzOdMPDqveDv3vDKSjtq6RRvN-j_s2J7aud2ySDAB0,5986 +numpy/typing/tests/data/reveal/lib_utils.pyi,sha256=_zj7WGYGYMFXAHLK-F11aeFfDvjRvFARUjoXhbXn8V0,1049 +numpy/typing/tests/data/reveal/lib_version.pyi,sha256=UCioUeykot8-nWL6goKxZnKZxtgB4lFEi9wdN_xyF1U,672 +numpy/typing/tests/data/reveal/linalg.pyi,sha256=LPaY-RyYL7Xt3djCgNaWEgI8beI9Eo_XnvOwi6Y7-eo,4877 +numpy/typing/tests/data/reveal/matrix.pyi,sha256=ciJXsn5v2O1IZ3VEn5Ilp8-40NTQokfrOOgVXMFsvLo,2922 +numpy/typing/tests/data/reveal/memmap.pyi,sha256=A5PovMzjRp2zslF1vw3TdTQjj4Y0dIEJ__HDBV_svGM,842 +numpy/typing/tests/data/reveal/mod.pyi,sha256=-CNWft2jQGSdrO8dYRgwbl7OhL3a78Zo60JVmiY-gQI,5666 +numpy/typing/tests/data/reveal/modules.pyi,sha256=0WPq7A-aqWkJsV-IA1_7dFNCcxBacj1AWExaXbXErG4,1958 +numpy/typing/tests/data/reveal/multiarray.pyi,sha256=6MvfNKihK-oN6QwG9HFNelgheo4lnL0FCrmIF_qxdoA,5326 +numpy/typing/tests/data/reveal/nbit_base_example.pyi,sha256=DRUMGatQvQXTuovKEMF4dzazIU6it6FU53LkOEo2vNo,657 +numpy/typing/tests/data/reveal/ndarray_conversion.pyi,sha256=BfjQD8U756l4gOfY0LD47HhDRxbq0yCFfEFKvbXs7Rs,1791 +numpy/typing/tests/data/reveal/ndarray_misc.pyi,sha256=0EN-a47Msn4pZgKVdD-GrXCCmt-oxjlov5rszchBmOI,7126 +numpy/typing/tests/data/reveal/ndarray_shape_manipulation.pyi,sha256=QDQ9g6l-e73pTJp-Dosiynb-okbqi91D4KirjhIjcv4,1233 +numpy/typing/tests/data/reveal/nditer.pyi,sha256=VFXnT75BgWSUpb-dD-q5cZkfeOqsk-x9cH626g9FWT4,2021 +numpy/typing/tests/data/reveal/nested_sequence.pyi,sha256=IQyRlXduk-ZEakOtoliMLCqNgGbeg0mzZf-a-a3Gq_0,734 +numpy/typing/tests/data/reveal/npyio.pyi,sha256=YXagt2J-1suu5WXZ_si5NuJf7sHj_7NlaSLqQkam1Po,4209 +numpy/typing/tests/data/reveal/numeric.pyi,sha256=aJKnav-X45tjSFfgGD4iCetwEFcJXdNgU7valktjiCg,6160 +numpy/typing/tests/data/reveal/numerictypes.pyi,sha256=-YQRhwjBjsFJHjpGCRqzafNnKDdsmbBHbmPwccP0pLI,2487 +numpy/typing/tests/data/reveal/random.pyi,sha256=s6T074ZIpGAUqHnA-yAlozTLvt7PNBjCBqd-nGMqWGg,104091 +numpy/typing/tests/data/reveal/rec.pyi,sha256=DbRVk6lc7-3qPe-7Q26tUWpdaH9B4UVoQSYrRGJUo1Q,3858 +numpy/typing/tests/data/reveal/scalars.pyi,sha256=Qn3B3rsqSN397Jh25xs4odt2pfCQtWkoJe-e0-oX8d4,4790 +numpy/typing/tests/data/reveal/shape_base.pyi,sha256=YjiVukrK6OOydvopOaOmeAIIa0YQ2hn9_I_-FyYkHVU,2427 +numpy/typing/tests/data/reveal/stride_tricks.pyi,sha256=EBZR8gSP385nhotwJ3GH9DOUD2q5nUEYbXfhLo5xrPo,1542 +numpy/typing/tests/data/reveal/testing.pyi,sha256=_WOAj_t5SWYiqN0KG26Mza8RvaD3WAa7rFUlgksjLms,8611 +numpy/typing/tests/data/reveal/twodim_base.pyi,sha256=ZdNVo2HIJcx8iF9PA-z5W3Bs0hWM2nlVdbhLuAQlljM,3132 +numpy/typing/tests/data/reveal/type_check.pyi,sha256=yZSp50TtvPqv_PN7zmVcNOVUTUXMNYFGcguMNj25E9Y,3044 +numpy/typing/tests/data/reveal/ufunc_config.pyi,sha256=buwSvat3SVFAFl5k8TL6Mgpi32o6hHZYZ2Lpn6AHdEU,1327 +numpy/typing/tests/data/reveal/ufunclike.pyi,sha256=V_gLcZVrTXJ21VkUMwA0HyxUgA1r6OzjsdJegaKL2GE,1329 +numpy/typing/tests/data/reveal/ufuncs.pyi,sha256=VnwYr5KT_FLKfc0wV7dtNz7bNtaC9VIQt-oz56Hb5EE,2798 +numpy/typing/tests/data/reveal/warnings_and_errors.pyi,sha256=ImMlPt2PQBtX8Qf1EZFmLjNWm8fPE6IWQ_deaq_-85s,538 +numpy/typing/tests/test_isfile.py,sha256=BhKZs4-LrhFUfKjcG0yelySjE6ZITMxGIBYWGDHMRb8,864 +numpy/typing/tests/test_runtime.py,sha256=2qu8JEliITnZCBJ_QJpohacj_OQ08o73ixS2w2ooNXI,3275 +numpy/typing/tests/test_typing.py,sha256=Da1ZOFjtPh_Mvb5whpI-okBJdgLOAfJtJNyG6leGFoQ,8743 +numpy/version.py,sha256=OTLnSh0NGfWyL8VrnIj0Ndt_KZOTl1Z-kD9Cf-jRMmY,216 diff --git a/lib/python3.12/site-packages/numpy-1.26.4.dist-info/REQUESTED b/lib/python3.12/site-packages/numpy-1.26.4.dist-info/REQUESTED new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lib/python3.12/site-packages/numpy-1.26.4.dist-info/WHEEL b/lib/python3.12/site-packages/numpy-1.26.4.dist-info/WHEEL new file mode 100644 index 0000000000000000000000000000000000000000..d98ef534f680b37433e9ab0f8470bdbe56c303d8 --- /dev/null +++ b/lib/python3.12/site-packages/numpy-1.26.4.dist-info/WHEEL @@ -0,0 +1,6 @@ +Wheel-Version: 1.0 +Generator: meson +Root-Is-Purelib: false +Tag: cp312-cp312-manylinux_2_17_x86_64 +Tag: cp312-cp312-manylinux2014_x86_64 + diff --git a/lib/python3.12/site-packages/numpy-1.26.4.dist-info/entry_points.txt b/lib/python3.12/site-packages/numpy-1.26.4.dist-info/entry_points.txt new file mode 100644 index 0000000000000000000000000000000000000000..450d8ef27b9d42527fde6aced068dd7e13f2c9a3 --- /dev/null +++ b/lib/python3.12/site-packages/numpy-1.26.4.dist-info/entry_points.txt @@ -0,0 +1,9 @@ +[array_api] +numpy = numpy.array_api + +[pyinstaller40] +hook-dirs = numpy:_pyinstaller_hooks_dir + +[console_scripts] +f2py = numpy.f2py.f2py2e:main + diff --git a/lib/python3.12/site-packages/nvidia_cusolver_cu12-11.6.1.9.dist-info/INSTALLER b/lib/python3.12/site-packages/nvidia_cusolver_cu12-11.6.1.9.dist-info/INSTALLER new file mode 100644 index 0000000000000000000000000000000000000000..a1b589e38a32041e49332e5e81c2d363dc418d68 --- /dev/null +++ b/lib/python3.12/site-packages/nvidia_cusolver_cu12-11.6.1.9.dist-info/INSTALLER @@ -0,0 +1 @@ +pip diff --git a/lib/python3.12/site-packages/nvidia_cusolver_cu12-11.6.1.9.dist-info/License.txt b/lib/python3.12/site-packages/nvidia_cusolver_cu12-11.6.1.9.dist-info/License.txt new file mode 100644 index 0000000000000000000000000000000000000000..b491c70e0aef319022ded661e111ddbd45b8a17f --- /dev/null +++ b/lib/python3.12/site-packages/nvidia_cusolver_cu12-11.6.1.9.dist-info/License.txt @@ -0,0 +1,1568 @@ +End User License Agreement +-------------------------- + + +Preface +------- + +The Software License Agreement in Chapter 1 and the Supplement +in Chapter 2 contain license terms and conditions that govern +the use of NVIDIA software. By accepting this agreement, you +agree to comply with all the terms and conditions applicable +to the product(s) included herein. + + +NVIDIA Driver + + +Description + +This package contains the operating system driver and +fundamental system software components for NVIDIA GPUs. + + +NVIDIA CUDA Toolkit + + +Description + +The NVIDIA CUDA Toolkit provides command-line and graphical +tools for building, debugging and optimizing the performance +of applications accelerated by NVIDIA GPUs, runtime and math +libraries, and documentation including programming guides, +user manuals, and API references. + + +Default Install Location of CUDA Toolkit + +Windows platform: + +%ProgramFiles%\NVIDIA GPU Computing Toolkit\CUDA\v#.# + +Linux platform: + +/usr/local/cuda-#.# + +Mac platform: + +/Developer/NVIDIA/CUDA-#.# + + +NVIDIA CUDA Samples + + +Description + +This package includes over 100+ CUDA examples that demonstrate +various CUDA programming principles, and efficient CUDA +implementation of algorithms in specific application domains. + + +Default Install Location of CUDA Samples + +Windows platform: + +%ProgramData%\NVIDIA Corporation\CUDA Samples\v#.# + +Linux platform: + +/usr/local/cuda-#.#/samples + +and + +$HOME/NVIDIA_CUDA-#.#_Samples + +Mac platform: + +/Developer/NVIDIA/CUDA-#.#/samples + + +NVIDIA Nsight Visual Studio Edition (Windows only) + + +Description + +NVIDIA Nsight Development Platform, Visual Studio Edition is a +development environment integrated into Microsoft Visual +Studio that provides tools for debugging, profiling, analyzing +and optimizing your GPU computing and graphics applications. + + +Default Install Location of Nsight Visual Studio Edition + +Windows platform: + +%ProgramFiles(x86)%\NVIDIA Corporation\Nsight Visual Studio Edition #.# + + +1. License Agreement for NVIDIA Software Development Kits +--------------------------------------------------------- + + +Release Date: July 26, 2018 +--------------------------- + + +Important NoticeRead before downloading, installing, +copying or using the licensed software: +------------------------------------------------------- + +This license agreement, including exhibits attached +("Agreement”) is a legal agreement between you and NVIDIA +Corporation ("NVIDIA") and governs your use of a NVIDIA +software development kit (“SDK”). + +Each SDK has its own set of software and materials, but here +is a description of the types of items that may be included in +a SDK: source code, header files, APIs, data sets and assets +(examples include images, textures, models, scenes, videos, +native API input/output files), binary software, sample code, +libraries, utility programs, programming code and +documentation. + +This Agreement can be accepted only by an adult of legal age +of majority in the country in which the SDK is used. + +If you are entering into this Agreement on behalf of a company +or other legal entity, you represent that you have the legal +authority to bind the entity to this Agreement, in which case +“you” will mean the entity you represent. + +If you don’t have the required age or authority to accept +this Agreement, or if you don’t accept all the terms and +conditions of this Agreement, do not download, install or use +the SDK. + +You agree to use the SDK only for purposes that are permitted +by (a) this Agreement, and (b) any applicable law, regulation +or generally accepted practices or guidelines in the relevant +jurisdictions. + + +1.1. License + + +1.1.1. License Grant + +Subject to the terms of this Agreement, NVIDIA hereby grants +you a non-exclusive, non-transferable license, without the +right to sublicense (except as expressly provided in this +Agreement) to: + + 1. Install and use the SDK, + + 2. Modify and create derivative works of sample source code + delivered in the SDK, and + + 3. Distribute those portions of the SDK that are identified + in this Agreement as distributable, as incorporated in + object code format into a software application that meets + the distribution requirements indicated in this Agreement. + + +1.1.2. Distribution Requirements + +These are the distribution requirements for you to exercise +the distribution grant: + + 1. Your application must have material additional + functionality, beyond the included portions of the SDK. + + 2. The distributable portions of the SDK shall only be + accessed by your application. + + 3. The following notice shall be included in modifications + and derivative works of sample source code distributed: + “This software contains source code provided by NVIDIA + Corporation.” + + 4. Unless a developer tool is identified in this Agreement + as distributable, it is delivered for your internal use + only. + + 5. The terms under which you distribute your application + must be consistent with the terms of this Agreement, + including (without limitation) terms relating to the + license grant and license restrictions and protection of + NVIDIA’s intellectual property rights. Additionally, you + agree that you will protect the privacy, security and + legal rights of your application users. + + 6. You agree to notify NVIDIA in writing of any known or + suspected distribution or use of the SDK not in compliance + with the requirements of this Agreement, and to enforce + the terms of your agreements with respect to distributed + SDK. + + +1.1.3. Authorized Users + +You may allow employees and contractors of your entity or of +your subsidiary(ies) to access and use the SDK from your +secure network to perform work on your behalf. + +If you are an academic institution you may allow users +enrolled or employed by the academic institution to access and +use the SDK from your secure network. + +You are responsible for the compliance with the terms of this +Agreement by your authorized users. If you become aware that +your authorized users didn’t follow the terms of this +Agreement, you agree to take reasonable steps to resolve the +non-compliance and prevent new occurrences. + + +1.1.4. Pre-Release SDK + +The SDK versions identified as alpha, beta, preview or +otherwise as pre-release, may not be fully functional, may +contain errors or design flaws, and may have reduced or +different security, privacy, accessibility, availability, and +reliability standards relative to commercial versions of +NVIDIA software and materials. Use of a pre-release SDK may +result in unexpected results, loss of data, project delays or +other unpredictable damage or loss. + +You may use a pre-release SDK at your own risk, understanding +that pre-release SDKs are not intended for use in production +or business-critical systems. + +NVIDIA may choose not to make available a commercial version +of any pre-release SDK. NVIDIA may also choose to abandon +development and terminate the availability of a pre-release +SDK at any time without liability. + + +1.1.5. Updates + +NVIDIA may, at its option, make available patches, workarounds +or other updates to this SDK. Unless the updates are provided +with their separate governing terms, they are deemed part of +the SDK licensed to you as provided in this Agreement. You +agree that the form and content of the SDK that NVIDIA +provides may change without prior notice to you. While NVIDIA +generally maintains compatibility between versions, NVIDIA may +in some cases make changes that introduce incompatibilities in +future versions of the SDK. + + +1.1.6. Third Party Licenses + +The SDK may come bundled with, or otherwise include or be +distributed with, third party software licensed by a NVIDIA +supplier and/or open source software provided under an open +source license. Use of third party software is subject to the +third-party license terms, or in the absence of third party +terms, the terms of this Agreement. Copyright to third party +software is held by the copyright holders indicated in the +third-party software or license. + + +1.1.7. Reservation of Rights + +NVIDIA reserves all rights, title, and interest in and to the +SDK, not expressly granted to you under this Agreement. + + +1.2. Limitations + +The following license limitations apply to your use of the +SDK: + + 1. You may not reverse engineer, decompile or disassemble, + or remove copyright or other proprietary notices from any + portion of the SDK or copies of the SDK. + + 2. Except as expressly provided in this Agreement, you may + not copy, sell, rent, sublicense, transfer, distribute, + modify, or create derivative works of any portion of the + SDK. For clarity, you may not distribute or sublicense the + SDK as a stand-alone product. + + 3. Unless you have an agreement with NVIDIA for this + purpose, you may not indicate that an application created + with the SDK is sponsored or endorsed by NVIDIA. + + 4. You may not bypass, disable, or circumvent any + encryption, security, digital rights management or + authentication mechanism in the SDK. + + 5. You may not use the SDK in any manner that would cause it + to become subject to an open source software license. As + examples, licenses that require as a condition of use, + modification, and/or distribution that the SDK be: + + a. Disclosed or distributed in source code form; + + b. Licensed for the purpose of making derivative works; + or + + c. Redistributable at no charge. + + 6. Unless you have an agreement with NVIDIA for this + purpose, you may not use the SDK with any system or + application where the use or failure of the system or + application can reasonably be expected to threaten or + result in personal injury, death, or catastrophic loss. + Examples include use in avionics, navigation, military, + medical, life support or other life critical applications. + NVIDIA does not design, test or manufacture the SDK for + these critical uses and NVIDIA shall not be liable to you + or any third party, in whole or in part, for any claims or + damages arising from such uses. + + 7. You agree to defend, indemnify and hold harmless NVIDIA + and its affiliates, and their respective employees, + contractors, agents, officers and directors, from and + against any and all claims, damages, obligations, losses, + liabilities, costs or debt, fines, restitutions and + expenses (including but not limited to attorney’s fees + and costs incident to establishing the right of + indemnification) arising out of or related to your use of + the SDK outside of the scope of this Agreement, or not in + compliance with its terms. + + +1.3. Ownership + + 1. NVIDIA or its licensors hold all rights, title and + interest in and to the SDK and its modifications and + derivative works, including their respective intellectual + property rights, subject to your rights described in this + section. This SDK may include software and materials from + NVIDIA’s licensors, and these licensors are intended + third party beneficiaries that may enforce this Agreement + with respect to their intellectual property rights. + + 2. You hold all rights, title and interest in and to your + applications and your derivative works of the sample + source code delivered in the SDK, including their + respective intellectual property rights, subject to + NVIDIA’s rights described in this section. + + 3. You may, but don’t have to, provide to NVIDIA + suggestions, feature requests or other feedback regarding + the SDK, including possible enhancements or modifications + to the SDK. For any feedback that you voluntarily provide, + you hereby grant NVIDIA and its affiliates a perpetual, + non-exclusive, worldwide, irrevocable license to use, + reproduce, modify, license, sublicense (through multiple + tiers of sublicensees), and distribute (through multiple + tiers of distributors) it without the payment of any + royalties or fees to you. NVIDIA will use feedback at its + choice. NVIDIA is constantly looking for ways to improve + its products, so you may send feedback to NVIDIA through + the developer portal at https://developer.nvidia.com. + + +1.4. No Warranties + +THE SDK IS PROVIDED BY NVIDIA “AS IS” AND “WITH ALL +FAULTS.” TO THE MAXIMUM EXTENT PERMITTED BY LAW, NVIDIA AND +ITS AFFILIATES EXPRESSLY DISCLAIM ALL WARRANTIES OF ANY KIND +OR NATURE, WHETHER EXPRESS, IMPLIED OR STATUTORY, INCLUDING, +BUT NOT LIMITED TO, ANY WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE, TITLE, NON-INFRINGEMENT, OR THE +ABSENCE OF ANY DEFECTS THEREIN, WHETHER LATENT OR PATENT. NO +WARRANTY IS MADE ON THE BASIS OF TRADE USAGE, COURSE OF +DEALING OR COURSE OF TRADE. + + +1.5. Limitation of Liability + +TO THE MAXIMUM EXTENT PERMITTED BY LAW, NVIDIA AND ITS +AFFILIATES SHALL NOT BE LIABLE FOR ANY SPECIAL, INCIDENTAL, +PUNITIVE OR CONSEQUENTIAL DAMAGES, OR ANY LOST PROFITS, LOSS +OF USE, LOSS OF DATA OR LOSS OF GOODWILL, OR THE COSTS OF +PROCURING SUBSTITUTE PRODUCTS, ARISING OUT OF OR IN CONNECTION +WITH THIS AGREEMENT OR THE USE OR PERFORMANCE OF THE SDK, +WHETHER SUCH LIABILITY ARISES FROM ANY CLAIM BASED UPON BREACH +OF CONTRACT, BREACH OF WARRANTY, TORT (INCLUDING NEGLIGENCE), +PRODUCT LIABILITY OR ANY OTHER CAUSE OF ACTION OR THEORY OF +LIABILITY. IN NO EVENT WILL NVIDIA’S AND ITS AFFILIATES +TOTAL CUMULATIVE LIABILITY UNDER OR ARISING OUT OF THIS +AGREEMENT EXCEED US$10.00. THE NATURE OF THE LIABILITY OR THE +NUMBER OF CLAIMS OR SUITS SHALL NOT ENLARGE OR EXTEND THIS +LIMIT. + +These exclusions and limitations of liability shall apply +regardless if NVIDIA or its affiliates have been advised of +the possibility of such damages, and regardless of whether a +remedy fails its essential purpose. These exclusions and +limitations of liability form an essential basis of the +bargain between the parties, and, absent any of these +exclusions or limitations of liability, the provisions of this +Agreement, including, without limitation, the economic terms, +would be substantially different. + + +1.6. Termination + + 1. This Agreement will continue to apply until terminated by + either you or NVIDIA as described below. + + 2. If you want to terminate this Agreement, you may do so by + stopping to use the SDK. + + 3. NVIDIA may, at any time, terminate this Agreement if: + + a. (i) you fail to comply with any term of this + Agreement and the non-compliance is not fixed within + thirty (30) days following notice from NVIDIA (or + immediately if you violate NVIDIA’s intellectual + property rights); + + b. (ii) you commence or participate in any legal + proceeding against NVIDIA with respect to the SDK; or + + c. (iii) NVIDIA decides to no longer provide the SDK in + a country or, in NVIDIA’s sole discretion, the + continued use of it is no longer commercially viable. + + 4. Upon any termination of this Agreement, you agree to + promptly discontinue use of the SDK and destroy all copies + in your possession or control. Your prior distributions in + accordance with this Agreement are not affected by the + termination of this Agreement. Upon written request, you + will certify in writing that you have complied with your + commitments under this section. Upon any termination of + this Agreement all provisions survive except for the + license grant provisions. + + +1.7. General + +If you wish to assign this Agreement or your rights and +obligations, including by merger, consolidation, dissolution +or operation of law, contact NVIDIA to ask for permission. Any +attempted assignment not approved by NVIDIA in writing shall +be void and of no effect. NVIDIA may assign, delegate or +transfer this Agreement and its rights and obligations, and if +to a non-affiliate you will be notified. + +You agree to cooperate with NVIDIA and provide reasonably +requested information to verify your compliance with this +Agreement. + +This Agreement will be governed in all respects by the laws of +the United States and of the State of Delaware as those laws +are applied to contracts entered into and performed entirely +within Delaware by Delaware residents, without regard to the +conflicts of laws principles. The United Nations Convention on +Contracts for the International Sale of Goods is specifically +disclaimed. You agree to all terms of this Agreement in the +English language. + +The state or federal courts residing in Santa Clara County, +California shall have exclusive jurisdiction over any dispute +or claim arising out of this Agreement. Notwithstanding this, +you agree that NVIDIA shall still be allowed to apply for +injunctive remedies or an equivalent type of urgent legal +relief in any jurisdiction. + +If any court of competent jurisdiction determines that any +provision of this Agreement is illegal, invalid or +unenforceable, such provision will be construed as limited to +the extent necessary to be consistent with and fully +enforceable under the law and the remaining provisions will +remain in full force and effect. Unless otherwise specified, +remedies are cumulative. + +Each party acknowledges and agrees that the other is an +independent contractor in the performance of this Agreement. + +The SDK has been developed entirely at private expense and is +“commercial items” consisting of “commercial computer +software” and “commercial computer software +documentation” provided with RESTRICTED RIGHTS. Use, +duplication or disclosure by the U.S. Government or a U.S. +Government subcontractor is subject to the restrictions in +this Agreement pursuant to DFARS 227.7202-3(a) or as set forth +in subparagraphs (c)(1) and (2) of the Commercial Computer +Software - Restricted Rights clause at FAR 52.227-19, as +applicable. Contractor/manufacturer is NVIDIA, 2788 San Tomas +Expressway, Santa Clara, CA 95051. + +The SDK is subject to United States export laws and +regulations. You agree that you will not ship, transfer or +export the SDK into any country, or use the SDK in any manner, +prohibited by the United States Bureau of Industry and +Security or economic sanctions regulations administered by the +U.S. Department of Treasury’s Office of Foreign Assets +Control (OFAC), or any applicable export laws, restrictions or +regulations. These laws include restrictions on destinations, +end users and end use. By accepting this Agreement, you +confirm that you are not a resident or citizen of any country +currently embargoed by the U.S. and that you are not otherwise +prohibited from receiving the SDK. + +Any notice delivered by NVIDIA to you under this Agreement +will be delivered via mail, email or fax. You agree that any +notices that NVIDIA sends you electronically will satisfy any +legal communication requirements. Please direct your legal +notices or other correspondence to NVIDIA Corporation, 2788 +San Tomas Expressway, Santa Clara, California 95051, United +States of America, Attention: Legal Department. + +This Agreement and any exhibits incorporated into this +Agreement constitute the entire agreement of the parties with +respect to the subject matter of this Agreement and supersede +all prior negotiations or documentation exchanged between the +parties relating to this SDK license. Any additional and/or +conflicting terms on documents issued by you are null, void, +and invalid. Any amendment or waiver under this Agreement +shall be in writing and signed by representatives of both +parties. + + +2. CUDA Toolkit Supplement to Software License Agreement for +NVIDIA Software Development Kits +------------------------------------------------------------ + + +Release date: August 16, 2018 +----------------------------- + +The terms in this supplement govern your use of the NVIDIA +CUDA Toolkit SDK under the terms of your license agreement +(“Agreement”) as modified by this supplement. Capitalized +terms used but not defined below have the meaning assigned to +them in the Agreement. + +This supplement is an exhibit to the Agreement and is +incorporated as an integral part of the Agreement. In the +event of conflict between the terms in this supplement and the +terms in the Agreement, the terms in this supplement govern. + + +2.1. License Scope + +The SDK is licensed for you to develop applications only for +use in systems with NVIDIA GPUs. + + +2.2. Distribution + +The portions of the SDK that are distributable under the +Agreement are listed in Attachment A. + + +2.3. Operating Systems + +Those portions of the SDK designed exclusively for use on the +Linux or FreeBSD operating systems, or other operating systems +derived from the source code to these operating systems, may +be copied and redistributed for use in accordance with this +Agreement, provided that the object code files are not +modified in any way (except for unzipping of compressed +files). + + +2.4. Audio and Video Encoders and Decoders + +You acknowledge and agree that it is your sole responsibility +to obtain any additional third-party licenses required to +make, have made, use, have used, sell, import, and offer for +sale your products or services that include or incorporate any +third-party software and content relating to audio and/or +video encoders and decoders from, including but not limited +to, Microsoft, Thomson, Fraunhofer IIS, Sisvel S.p.A., +MPEG-LA, and Coding Technologies. NVIDIA does not grant to you +under this Agreement any necessary patent or other rights with +respect to any audio and/or video encoders and decoders. + + +2.5. Licensing + +If the distribution terms in this Agreement are not suitable +for your organization, or for any questions regarding this +Agreement, please contact NVIDIA at +nvidia-compute-license-questions@nvidia.com. + + +2.6. Attachment A + +The following portions of the SDK are distributable under the +Agreement: + +Component + +CUDA Runtime + +Windows + +cudart.dll, cudart_static.lib, cudadevrt.lib + +Mac OSX + +libcudart.dylib, libcudart_static.a, libcudadevrt.a + +Linux + +libcudart.so, libcudart_static.a, libcudadevrt.a + +Android + +libcudart.so, libcudart_static.a, libcudadevrt.a + +Component + +CUDA FFT Library + +Windows + +cufft.dll, cufftw.dll, cufft.lib, cufftw.lib + +Mac OSX + +libcufft.dylib, libcufft_static.a, libcufftw.dylib, +libcufftw_static.a + +Linux + +libcufft.so, libcufft_static.a, libcufftw.so, +libcufftw_static.a + +Android + +libcufft.so, libcufft_static.a, libcufftw.so, +libcufftw_static.a + +Component + +CUDA BLAS Library + +Windows + +cublas.dll, cublasLt.dll + +Mac OSX + +libcublas.dylib, libcublasLt.dylib, libcublas_static.a, +libcublasLt_static.a + +Linux + +libcublas.so, libcublasLt.so, libcublas_static.a, +libcublasLt_static.a + +Android + +libcublas.so, libcublasLt.so, libcublas_static.a, +libcublasLt_static.a + +Component + +NVIDIA "Drop-in" BLAS Library + +Windows + +nvblas.dll + +Mac OSX + +libnvblas.dylib + +Linux + +libnvblas.so + +Component + +CUDA Sparse Matrix Library + +Windows + +cusparse.dll, cusparse.lib + +Mac OSX + +libcusparse.dylib, libcusparse_static.a + +Linux + +libcusparse.so, libcusparse_static.a + +Android + +libcusparse.so, libcusparse_static.a + +Component + +CUDA Linear Solver Library + +Windows + +cusolver.dll, cusolver.lib + +Mac OSX + +libcusolver.dylib, libcusolver_static.a + +Linux + +libcusolver.so, libcusolver_static.a + +Android + +libcusolver.so, libcusolver_static.a + +Component + +CUDA Random Number Generation Library + +Windows + +curand.dll, curand.lib + +Mac OSX + +libcurand.dylib, libcurand_static.a + +Linux + +libcurand.so, libcurand_static.a + +Android + +libcurand.so, libcurand_static.a + +Component + +CUDA Accelerated Graph Library + +Component + +NVIDIA Performance Primitives Library + +Windows + +nppc.dll, nppc.lib, nppial.dll, nppial.lib, nppicc.dll, +nppicc.lib, nppicom.dll, nppicom.lib, nppidei.dll, +nppidei.lib, nppif.dll, nppif.lib, nppig.dll, nppig.lib, +nppim.dll, nppim.lib, nppist.dll, nppist.lib, nppisu.dll, +nppisu.lib, nppitc.dll, nppitc.lib, npps.dll, npps.lib + +Mac OSX + +libnppc.dylib, libnppc_static.a, libnppial.dylib, +libnppial_static.a, libnppicc.dylib, libnppicc_static.a, +libnppicom.dylib, libnppicom_static.a, libnppidei.dylib, +libnppidei_static.a, libnppif.dylib, libnppif_static.a, +libnppig.dylib, libnppig_static.a, libnppim.dylib, +libnppisu_static.a, libnppitc.dylib, libnppitc_static.a, +libnpps.dylib, libnpps_static.a + +Linux + +libnppc.so, libnppc_static.a, libnppial.so, +libnppial_static.a, libnppicc.so, libnppicc_static.a, +libnppicom.so, libnppicom_static.a, libnppidei.so, +libnppidei_static.a, libnppif.so, libnppif_static.a +libnppig.so, libnppig_static.a, libnppim.so, +libnppim_static.a, libnppist.so, libnppist_static.a, +libnppisu.so, libnppisu_static.a, libnppitc.so +libnppitc_static.a, libnpps.so, libnpps_static.a + +Android + +libnppc.so, libnppc_static.a, libnppial.so, +libnppial_static.a, libnppicc.so, libnppicc_static.a, +libnppicom.so, libnppicom_static.a, libnppidei.so, +libnppidei_static.a, libnppif.so, libnppif_static.a +libnppig.so, libnppig_static.a, libnppim.so, +libnppim_static.a, libnppist.so, libnppist_static.a, +libnppisu.so, libnppisu_static.a, libnppitc.so +libnppitc_static.a, libnpps.so, libnpps_static.a + +Component + +NVIDIA JPEG Library + +Linux + +libnvjpeg.so, libnvjpeg_static.a + +Component + +Internal common library required for statically linking to +cuBLAS, cuSPARSE, cuFFT, cuRAND, nvJPEG and NPP + +Mac OSX + +libculibos.a + +Linux + +libculibos.a + +Component + +NVIDIA Runtime Compilation Library and Header + +All + +nvrtc.h + +Windows + +nvrtc.dll, nvrtc-builtins.dll + +Mac OSX + +libnvrtc.dylib, libnvrtc-builtins.dylib + +Linux + +libnvrtc.so, libnvrtc-builtins.so + +Component + +NVIDIA Optimizing Compiler Library + +Windows + +nvvm.dll + +Mac OSX + +libnvvm.dylib + +Linux + +libnvvm.so + +Component + +NVIDIA Common Device Math Functions Library + +Windows + +libdevice.10.bc + +Mac OSX + +libdevice.10.bc + +Linux + +libdevice.10.bc + +Component + +CUDA Occupancy Calculation Header Library + +All + +cuda_occupancy.h + +Component + +CUDA Half Precision Headers + +All + +cuda_fp16.h, cuda_fp16.hpp + +Component + +CUDA Profiling Tools Interface (CUPTI) Library + +Windows + +cupti.dll + +Mac OSX + +libcupti.dylib + +Linux + +libcupti.so + +Component + +NVIDIA Tools Extension Library + +Windows + +nvToolsExt.dll, nvToolsExt.lib + +Mac OSX + +libnvToolsExt.dylib + +Linux + +libnvToolsExt.so + +Component + +NVIDIA CUDA Driver Libraries + +Linux + +libcuda.so, libnvidia-fatbinaryloader.so, +libnvidia-ptxjitcompiler.so + +The NVIDIA CUDA Driver Libraries are only distributable in +applications that meet this criteria: + + 1. The application was developed starting from a NVIDIA CUDA + container obtained from Docker Hub or the NVIDIA GPU + Cloud, and + + 2. The resulting application is packaged as a Docker + container and distributed to users on Docker Hub or the + NVIDIA GPU Cloud only. + + +2.7. Attachment B + + +Additional Licensing Obligations + +The following third party components included in the SOFTWARE +are licensed to Licensee pursuant to the following terms and +conditions: + + 1. Licensee's use of the GDB third party component is + subject to the terms and conditions of GNU GPL v3: + + This product includes copyrighted third-party software licensed + under the terms of the GNU General Public License v3 ("GPL v3"). + All third-party software packages are copyright by their respective + authors. GPL v3 terms and conditions are hereby incorporated into + the Agreement by this reference: http://www.gnu.org/licenses/gpl.txt + + Consistent with these licensing requirements, the software + listed below is provided under the terms of the specified + open source software licenses. To obtain source code for + software provided under licenses that require + redistribution of source code, including the GNU General + Public License (GPL) and GNU Lesser General Public License + (LGPL), contact oss-requests@nvidia.com. This offer is + valid for a period of three (3) years from the date of the + distribution of this product by NVIDIA CORPORATION. + + Component License + CUDA-GDB GPL v3 + + 2. Licensee represents and warrants that any and all third + party licensing and/or royalty payment obligations in + connection with Licensee's use of the H.264 video codecs + are solely the responsibility of Licensee. + + 3. Licensee's use of the Thrust library is subject to the + terms and conditions of the Apache License Version 2.0. + All third-party software packages are copyright by their + respective authors. Apache License Version 2.0 terms and + conditions are hereby incorporated into the Agreement by + this reference. + http://www.apache.org/licenses/LICENSE-2.0.html + + In addition, Licensee acknowledges the following notice: + Thrust includes source code from the Boost Iterator, + Tuple, System, and Random Number libraries. + + Boost Software License - Version 1.0 - August 17th, 2003 + . . . . + + Permission is hereby granted, free of charge, to any person or + organization obtaining a copy of the software and accompanying + documentation covered by this license (the "Software") to use, + reproduce, display, distribute, execute, and transmit the Software, + and to prepare derivative works of the Software, and to permit + third-parties to whom the Software is furnished to do so, all + subject to the following: + + The copyright notices in the Software and this entire statement, + including the above license grant, this restriction and the following + disclaimer, must be included in all copies of the Software, in whole + or in part, and all derivative works of the Software, unless such + copies or derivative works are solely in the form of machine-executable + object code generated by a source language processor. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE AND + NON-INFRINGEMENT. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR + ANYONE DISTRIBUTING THE SOFTWARE BE LIABLE FOR ANY DAMAGES OR + OTHER LIABILITY, WHETHER IN CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + OTHER DEALINGS IN THE SOFTWARE. + + 4. Licensee's use of the LLVM third party component is + subject to the following terms and conditions: + + ====================================================== + LLVM Release License + ====================================================== + University of Illinois/NCSA + Open Source License + + Copyright (c) 2003-2010 University of Illinois at Urbana-Champaign. + All rights reserved. + + Developed by: + + LLVM Team + + University of Illinois at Urbana-Champaign + + http://llvm.org + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to + deal with the Software without restriction, including without limitation the + rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + sell copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + * Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimers. + + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimers in the + documentation and/or other materials provided with the distribution. + + * Neither the names of the LLVM Team, University of Illinois at Urbana- + Champaign, nor the names of its contributors may be used to endorse or + promote products derived from this Software without specific prior + written permission. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL + THE CONTRIBUTORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR + OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, + ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + DEALINGS WITH THE SOFTWARE. + + 5. Licensee's use (e.g. nvprof) of the PCRE third party + component is subject to the following terms and + conditions: + + ------------ + PCRE LICENCE + ------------ + PCRE is a library of functions to support regular expressions whose syntax + and semantics are as close as possible to those of the Perl 5 language. + Release 8 of PCRE is distributed under the terms of the "BSD" licence, as + specified below. The documentation for PCRE, supplied in the "doc" + directory, is distributed under the same terms as the software itself. The + basic library functions are written in C and are freestanding. Also + included in the distribution is a set of C++ wrapper functions, and a just- + in-time compiler that can be used to optimize pattern matching. These are + both optional features that can be omitted when the library is built. + + THE BASIC LIBRARY FUNCTIONS + --------------------------- + Written by: Philip Hazel + Email local part: ph10 + Email domain: cam.ac.uk + University of Cambridge Computing Service, + Cambridge, England. + Copyright (c) 1997-2012 University of Cambridge + All rights reserved. + + PCRE JUST-IN-TIME COMPILATION SUPPORT + ------------------------------------- + Written by: Zoltan Herczeg + Email local part: hzmester + Emain domain: freemail.hu + Copyright(c) 2010-2012 Zoltan Herczeg + All rights reserved. + + STACK-LESS JUST-IN-TIME COMPILER + -------------------------------- + Written by: Zoltan Herczeg + Email local part: hzmester + Emain domain: freemail.hu + Copyright(c) 2009-2012 Zoltan Herczeg + All rights reserved. + + THE C++ WRAPPER FUNCTIONS + ------------------------- + Contributed by: Google Inc. + Copyright (c) 2007-2012, Google Inc. + All rights reserved. + + THE "BSD" LICENCE + ----------------- + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + + * Neither the name of the University of Cambridge nor the name of Google + Inc. nor the names of their contributors may be used to endorse or + promote products derived from this software without specific prior + written permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE + LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + POSSIBILITY OF SUCH DAMAGE. + + 6. Some of the cuBLAS library routines were written by or + derived from code written by Vasily Volkov and are subject + to the Modified Berkeley Software Distribution License as + follows: + + Copyright (c) 2007-2009, Regents of the University of California + + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are + met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of the University of California, Berkeley nor + the names of its contributors may be used to endorse or promote + products derived from this software without specific prior + written permission. + + THIS SOFTWARE IS PROVIDED BY THE AUTHOR "AS IS" AND ANY EXPRESS OR + IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, + INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING + IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + POSSIBILITY OF SUCH DAMAGE. + + 7. Some of the cuBLAS library routines were written by or + derived from code written by Davide Barbieri and are + subject to the Modified Berkeley Software Distribution + License as follows: + + Copyright (c) 2008-2009 Davide Barbieri @ University of Rome Tor Vergata. + + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are + met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * The name of the author may not be used to endorse or promote + products derived from this software without specific prior + written permission. + + THIS SOFTWARE IS PROVIDED BY THE AUTHOR "AS IS" AND ANY EXPRESS OR + IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, + INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING + IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + POSSIBILITY OF SUCH DAMAGE. + + 8. Some of the cuBLAS library routines were derived from + code developed by the University of Tennessee and are + subject to the Modified Berkeley Software Distribution + License as follows: + + Copyright (c) 2010 The University of Tennessee. + + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are + met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer listed in this license in the documentation and/or + other materials provided with the distribution. + * Neither the name of the copyright holders nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + 9. Some of the cuBLAS library routines were written by or + derived from code written by Jonathan Hogg and are subject + to the Modified Berkeley Software Distribution License as + follows: + + Copyright (c) 2012, The Science and Technology Facilities Council (STFC). + + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are + met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of the STFC nor the names of its contributors + may be used to endorse or promote products derived from this + software without specific prior written permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE STFC BE + LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR + BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, + WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE + OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN + IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + 10. Some of the cuBLAS library routines were written by or + derived from code written by Ahmad M. Abdelfattah, David + Keyes, and Hatem Ltaief, and are subject to the Apache + License, Version 2.0, as follows: + + -- (C) Copyright 2013 King Abdullah University of Science and Technology + Authors: + Ahmad Abdelfattah (ahmad.ahmad@kaust.edu.sa) + David Keyes (david.keyes@kaust.edu.sa) + Hatem Ltaief (hatem.ltaief@kaust.edu.sa) + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions + are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of the King Abdullah University of Science and + Technology nor the names of its contributors may be used to endorse + or promote products derived from this software without specific prior + written permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE + + 11. Some of the cuSPARSE library routines were written by or + derived from code written by Li-Wen Chang and are subject + to the NCSA Open Source License as follows: + + Copyright (c) 2012, University of Illinois. + + All rights reserved. + + Developed by: IMPACT Group, University of Illinois, http://impact.crhc.illinois.edu + + Permission is hereby granted, free of charge, to any person obtaining + a copy of this software and associated documentation files (the + "Software"), to deal with the Software without restriction, including + without limitation the rights to use, copy, modify, merge, publish, + distribute, sublicense, and/or sell copies of the Software, and to + permit persons to whom the Software is furnished to do so, subject to + the following conditions: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimers in the documentation and/or other materials provided + with the distribution. + * Neither the names of IMPACT Group, University of Illinois, nor + the names of its contributors may be used to endorse or promote + products derived from this Software without specific prior + written permission. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + NONINFRINGEMENT. IN NO EVENT SHALL THE CONTRIBUTORS OR COPYRIGHT + HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR + IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS WITH THE + SOFTWARE. + + 12. Some of the cuRAND library routines were written by or + derived from code written by Mutsuo Saito and Makoto + Matsumoto and are subject to the following license: + + Copyright (c) 2009, 2010 Mutsuo Saito, Makoto Matsumoto and Hiroshima + University. All rights reserved. + + Copyright (c) 2011 Mutsuo Saito, Makoto Matsumoto, Hiroshima + University and University of Tokyo. All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are + met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of the Hiroshima University nor the names of + its contributors may be used to endorse or promote products + derived from this software without specific prior written + permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + 13. Some of the cuRAND library routines were derived from + code developed by D. E. Shaw Research and are subject to + the following license: + + Copyright 2010-2011, D. E. Shaw Research. + + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are + met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions, and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions, and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of D. E. Shaw Research nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + 14. Some of the Math library routines were written by or + derived from code developed by Norbert Juffa and are + subject to the following license: + + Copyright (c) 2015-2017, Norbert Juffa + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions + are met: + + 1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + 15. Licensee's use of the lz4 third party component is + subject to the following terms and conditions: + + Copyright (C) 2011-2013, Yann Collet. + BSD 2-Clause License (http://www.opensource.org/licenses/bsd-license.php) + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are + met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following disclaimer + in the documentation and/or other materials provided with the + distribution. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + 16. The NPP library uses code from the Boost Math Toolkit, + and is subject to the following license: + + Boost Software License - Version 1.0 - August 17th, 2003 + . . . . + + Permission is hereby granted, free of charge, to any person or + organization obtaining a copy of the software and accompanying + documentation covered by this license (the "Software") to use, + reproduce, display, distribute, execute, and transmit the Software, + and to prepare derivative works of the Software, and to permit + third-parties to whom the Software is furnished to do so, all + subject to the following: + + The copyright notices in the Software and this entire statement, + including the above license grant, this restriction and the following + disclaimer, must be included in all copies of the Software, in whole + or in part, and all derivative works of the Software, unless such + copies or derivative works are solely in the form of machine-executable + object code generated by a source language processor. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE AND + NON-INFRINGEMENT. IN NO EVENT SHALL THE COPYRIGHT HOLDERS OR + ANYONE DISTRIBUTING THE SOFTWARE BE LIABLE FOR ANY DAMAGES OR + OTHER LIABILITY, WHETHER IN CONTRACT, TORT OR OTHERWISE, ARISING + FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + OTHER DEALINGS IN THE SOFTWARE. + + 17. Portions of the Nsight Eclipse Edition is subject to the + following license: + + The Eclipse Foundation makes available all content in this plug-in + ("Content"). Unless otherwise indicated below, the Content is provided + to you under the terms and conditions of the Eclipse Public License + Version 1.0 ("EPL"). A copy of the EPL is available at http:// + www.eclipse.org/legal/epl-v10.html. For purposes of the EPL, "Program" + will mean the Content. + + If you did not receive this Content directly from the Eclipse + Foundation, the Content is being redistributed by another party + ("Redistributor") and different terms and conditions may apply to your + use of any object code in the Content. Check the Redistributor's + license that was provided with the Content. If no such license exists, + contact the Redistributor. Unless otherwise indicated below, the terms + and conditions of the EPL still apply to any source code in the + Content and such source code may be obtained at http://www.eclipse.org. + + 18. Some of the cuBLAS library routines uses code from + OpenAI, which is subject to the following license: + + License URL + https://github.com/openai/openai-gemm/blob/master/LICENSE + + License Text + The MIT License + + Copyright (c) 2016 OpenAI (http://openai.com), 2016 Google Inc. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + + 19. Licensee's use of the Visual Studio Setup Configuration + Samples is subject to the following license: + + The MIT License (MIT) + Copyright (C) Microsoft Corporation. All rights reserved. + + Permission is hereby granted, free of charge, to any person + obtaining a copy of this software and associated documentation + files (the "Software"), to deal in the Software without restriction, + including without limitation the rights to use, copy, modify, merge, + publish, distribute, sublicense, and/or sell copies of the Software, + and to permit persons to whom the Software is furnished to do so, + subject to the following conditions: + + The above copyright notice and this permission notice shall be included + in all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS + OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + 20. Licensee's use of linmath.h header for CPU functions for + GL vector/matrix operations from lunarG is subject to the + Apache License Version 2.0. + + 21. The DX12-CUDA sample uses the d3dx12.h header, which is + subject to the MIT license . + +----------------- diff --git a/lib/python3.12/site-packages/nvidia_cusolver_cu12-11.6.1.9.dist-info/METADATA b/lib/python3.12/site-packages/nvidia_cusolver_cu12-11.6.1.9.dist-info/METADATA new file mode 100644 index 0000000000000000000000000000000000000000..863ee1b108e8853325840f62c7659318ecac632f --- /dev/null +++ b/lib/python3.12/site-packages/nvidia_cusolver_cu12-11.6.1.9.dist-info/METADATA @@ -0,0 +1,38 @@ +Metadata-Version: 2.1 +Name: nvidia-cusolver-cu12 +Version: 11.6.1.9 +Summary: CUDA solver native runtime libraries +Home-page: https://developer.nvidia.com/cuda-zone +Author: Nvidia CUDA Installer Team +Author-email: cuda_installer@nvidia.com +License: NVIDIA Proprietary Software +Keywords: cuda,nvidia,runtime,machine learning,deep learning +Classifier: Development Status :: 4 - Beta +Classifier: Intended Audience :: Developers +Classifier: Intended Audience :: Education +Classifier: Intended Audience :: Science/Research +Classifier: License :: Other/Proprietary License +Classifier: Natural Language :: English +Classifier: Programming Language :: Python :: 3 +Classifier: Programming Language :: Python :: 3.5 +Classifier: Programming Language :: Python :: 3.6 +Classifier: Programming Language :: Python :: 3.7 +Classifier: Programming Language :: Python :: 3.8 +Classifier: Programming Language :: Python :: 3.9 +Classifier: Programming Language :: Python :: 3.10 +Classifier: Programming Language :: Python :: 3.11 +Classifier: Programming Language :: Python :: 3 :: Only +Classifier: Topic :: Scientific/Engineering +Classifier: Topic :: Scientific/Engineering :: Mathematics +Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence +Classifier: Topic :: Software Development +Classifier: Topic :: Software Development :: Libraries +Classifier: Operating System :: Microsoft :: Windows +Classifier: Operating System :: POSIX :: Linux +Requires-Python: >=3 +License-File: License.txt +Requires-Dist: nvidia-cublas-cu12 +Requires-Dist: nvidia-nvjitlink-cu12 +Requires-Dist: nvidia-cusparse-cu12 + +CUDA solver native runtime libraries diff --git a/lib/python3.12/site-packages/nvidia_cusolver_cu12-11.6.1.9.dist-info/RECORD b/lib/python3.12/site-packages/nvidia_cusolver_cu12-11.6.1.9.dist-info/RECORD new file mode 100644 index 0000000000000000000000000000000000000000..92d35600abe8f95263b77d605ad53ffabf96a8cb --- /dev/null +++ b/lib/python3.12/site-packages/nvidia_cusolver_cu12-11.6.1.9.dist-info/RECORD @@ -0,0 +1,23 @@ +nvidia/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +nvidia/__pycache__/__init__.cpython-312.pyc,, +nvidia/cusolver/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +nvidia/cusolver/__pycache__/__init__.cpython-312.pyc,, +nvidia/cusolver/include/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +nvidia/cusolver/include/__pycache__/__init__.cpython-312.pyc,, +nvidia/cusolver/include/cusolverDn.h,sha256=ZwkCl1sSSJFEAPIcR45LRiDhYr0lqsx8O0ZHtypY5IQ,149391 +nvidia/cusolver/include/cusolverMg.h,sha256=N8989nnS2BleeMyuftbQgBDJ4sMAkLPSnmy_S_7fxng,11549 +nvidia/cusolver/include/cusolverRf.h,sha256=7BZfWeuMJ8w1Pz4iZeGmwvDZbDNNq0ivG5MHtiATtls,14292 +nvidia/cusolver/include/cusolverSp.h,sha256=8fev0XawDBd0xrOxUlQ3WhclKlUuVAT64zKxwnP8iT0,32561 +nvidia/cusolver/include/cusolverSp_LOWLEVEL_PREVIEW.h,sha256=rTuS0rxwGV3bAz50ua59WVPQ9SvlijORj732oPejoCk,37495 +nvidia/cusolver/include/cusolver_common.h,sha256=PRpWKxS4IUGUl9-Yr1MS5kEVkWcf7yNwTJbn25cXPxI,8731 +nvidia/cusolver/lib/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +nvidia/cusolver/lib/__pycache__/__init__.cpython-312.pyc,, +nvidia/cusolver/lib/libcusolver.so.11,sha256=syDsTChhs1-SciHay8lHqrgEUJkCSHvkZnASeNblCtY,120220216 +nvidia/cusolver/lib/libcusolverMg.so.11,sha256=R2YnSaKV93G5Kr6NmdzV8VGVPVYGmhn0OXe5eGjsIes,82303400 +nvidia_cusolver_cu12-11.6.1.9.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4 +nvidia_cusolver_cu12-11.6.1.9.dist-info/License.txt,sha256=rW9YU_ugyg0VnQ9Y1JrkmDDC-Mk_epJki5zpCttMbM0,59262 +nvidia_cusolver_cu12-11.6.1.9.dist-info/METADATA,sha256=GyFsT_e6yEQGoYwHw2EZmgZBEXTF4K07q8dYUOQ-U28,1624 +nvidia_cusolver_cu12-11.6.1.9.dist-info/RECORD,, +nvidia_cusolver_cu12-11.6.1.9.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +nvidia_cusolver_cu12-11.6.1.9.dist-info/WHEEL,sha256=XDTs3wIbcE-BcRO08VJlZpA6z9OaC1mOKPCGGGwuM2g,109 +nvidia_cusolver_cu12-11.6.1.9.dist-info/top_level.txt,sha256=fTkAtiFuL16nUrB9ytDDtpytz2t0B4NvYTnRzwAhO14,7 diff --git a/lib/python3.12/site-packages/nvidia_cusolver_cu12-11.6.1.9.dist-info/REQUESTED b/lib/python3.12/site-packages/nvidia_cusolver_cu12-11.6.1.9.dist-info/REQUESTED new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lib/python3.12/site-packages/nvidia_cusolver_cu12-11.6.1.9.dist-info/WHEEL b/lib/python3.12/site-packages/nvidia_cusolver_cu12-11.6.1.9.dist-info/WHEEL new file mode 100644 index 0000000000000000000000000000000000000000..e6c30e957cfb045017a9fef3430bb8ee87c4a074 --- /dev/null +++ b/lib/python3.12/site-packages/nvidia_cusolver_cu12-11.6.1.9.dist-info/WHEEL @@ -0,0 +1,5 @@ +Wheel-Version: 1.0 +Generator: bdist_wheel (0.42.0) +Root-Is-Purelib: true +Tag: py3-none-manylinux2014_x86_64 + diff --git a/lib/python3.12/site-packages/nvidia_cusolver_cu12-11.6.1.9.dist-info/top_level.txt b/lib/python3.12/site-packages/nvidia_cusolver_cu12-11.6.1.9.dist-info/top_level.txt new file mode 100644 index 0000000000000000000000000000000000000000..862f7abf232cdfbb928609856247292e81c9decb --- /dev/null +++ b/lib/python3.12/site-packages/nvidia_cusolver_cu12-11.6.1.9.dist-info/top_level.txt @@ -0,0 +1 @@ +nvidia diff --git a/lib/python3.12/site-packages/nvidia_nccl_cu12-2.21.5.dist-info/INSTALLER b/lib/python3.12/site-packages/nvidia_nccl_cu12-2.21.5.dist-info/INSTALLER new file mode 100644 index 0000000000000000000000000000000000000000..a1b589e38a32041e49332e5e81c2d363dc418d68 --- /dev/null +++ b/lib/python3.12/site-packages/nvidia_nccl_cu12-2.21.5.dist-info/INSTALLER @@ -0,0 +1 @@ +pip diff --git a/lib/python3.12/site-packages/nvidia_nccl_cu12-2.21.5.dist-info/License.txt b/lib/python3.12/site-packages/nvidia_nccl_cu12-2.21.5.dist-info/License.txt new file mode 100644 index 0000000000000000000000000000000000000000..e318c666958b8bd82e56ef2c4314f67c3ba80b48 --- /dev/null +++ b/lib/python3.12/site-packages/nvidia_nccl_cu12-2.21.5.dist-info/License.txt @@ -0,0 +1,31 @@ + + Copyright (c) 2015-2019, NVIDIA CORPORATION. All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions + are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of NVIDIA CORPORATION, Lawrence Berkeley National + Laboratory, the U.S. Department of Energy, nor the names of their + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS ``AS IS'' AND ANY + EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR + PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR + CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, + PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY + OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + The U.S. Department of Energy funded the development of this software + under subcontract 7078610 with Lawrence Berkeley National Laboratory. + diff --git a/lib/python3.12/site-packages/nvidia_nccl_cu12-2.21.5.dist-info/METADATA b/lib/python3.12/site-packages/nvidia_nccl_cu12-2.21.5.dist-info/METADATA new file mode 100644 index 0000000000000000000000000000000000000000..2f3b7f31e80a2312665a165948f3e84ea943a978 --- /dev/null +++ b/lib/python3.12/site-packages/nvidia_nccl_cu12-2.21.5.dist-info/METADATA @@ -0,0 +1,35 @@ +Metadata-Version: 2.1 +Name: nvidia-nccl-cu12 +Version: 2.21.5 +Summary: NVIDIA Collective Communication Library (NCCL) Runtime +Home-page: https://developer.nvidia.com/cuda-zone +Author: Nvidia CUDA Installer Team +Author-email: cuda_installer@nvidia.com +License: NVIDIA Proprietary Software +Keywords: cuda,nvidia,runtime,machine learning,deep learning +Classifier: Development Status :: 4 - Beta +Classifier: Intended Audience :: Developers +Classifier: Intended Audience :: Education +Classifier: Intended Audience :: Science/Research +Classifier: License :: Other/Proprietary License +Classifier: Natural Language :: English +Classifier: Programming Language :: Python :: 3 +Classifier: Programming Language :: Python :: 3.5 +Classifier: Programming Language :: Python :: 3.6 +Classifier: Programming Language :: Python :: 3.7 +Classifier: Programming Language :: Python :: 3.8 +Classifier: Programming Language :: Python :: 3.9 +Classifier: Programming Language :: Python :: 3.10 +Classifier: Programming Language :: Python :: 3.11 +Classifier: Programming Language :: Python :: 3 :: Only +Classifier: Topic :: Scientific/Engineering +Classifier: Topic :: Scientific/Engineering :: Mathematics +Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence +Classifier: Topic :: Software Development +Classifier: Topic :: Software Development :: Libraries +Classifier: Operating System :: Microsoft :: Windows +Classifier: Operating System :: POSIX :: Linux +Requires-Python: >=3 +License-File: License.txt + +NCCL (pronounced "Nickel") is a stand-alone library of standard collective communication routines for GPUs, implementing all-reduce, all-gather, reduce, broadcast, and reduce-scatter. It has been optimized to achieve high bandwidth on any platform using PCIe, NVLink, NVswitch, as well as networking using InfiniBand Verbs or TCP/IP sockets. diff --git a/lib/python3.12/site-packages/nvidia_nccl_cu12-2.21.5.dist-info/RECORD b/lib/python3.12/site-packages/nvidia_nccl_cu12-2.21.5.dist-info/RECORD new file mode 100644 index 0000000000000000000000000000000000000000..5c6bc0c726fa3e0df3a3f33bdaa81b2e6a733e02 --- /dev/null +++ b/lib/python3.12/site-packages/nvidia_nccl_cu12-2.21.5.dist-info/RECORD @@ -0,0 +1,18 @@ +nvidia/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +nvidia/__pycache__/__init__.cpython-312.pyc,, +nvidia/nccl/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +nvidia/nccl/__pycache__/__init__.cpython-312.pyc,, +nvidia/nccl/include/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +nvidia/nccl/include/__pycache__/__init__.cpython-312.pyc,, +nvidia/nccl/include/nccl.h,sha256=LOTwUlbIa2tBwr21EwJTDUUek6el_KQxau6MTCVkZZo,18589 +nvidia/nccl/include/nccl_net.h,sha256=Q2yMEZBE6uKkX_nduRb3TaU64hC1jjOMstCypTKGSdk,25896 +nvidia/nccl/lib/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +nvidia/nccl/lib/__pycache__/__init__.cpython-312.pyc,, +nvidia/nccl/lib/libnccl.so.2,sha256=eN8vMfbbgULsVGoeWjHLBm94ktEtL2ZbRI-AaaCO-Ac,251616632 +nvidia_nccl_cu12-2.21.5.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4 +nvidia_nccl_cu12-2.21.5.dist-info/License.txt,sha256=92n6LTYyE_WZNm2kbiqNZQyG6q6EWuxNRLL1_QHU7Fk,1735 +nvidia_nccl_cu12-2.21.5.dist-info/METADATA,sha256=xTdJK3TpvqhmF4iFEMsBz3H7wjXhXKNGpIaS5fJRYEk,1834 +nvidia_nccl_cu12-2.21.5.dist-info/RECORD,, +nvidia_nccl_cu12-2.21.5.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +nvidia_nccl_cu12-2.21.5.dist-info/WHEEL,sha256=XDTs3wIbcE-BcRO08VJlZpA6z9OaC1mOKPCGGGwuM2g,109 +nvidia_nccl_cu12-2.21.5.dist-info/top_level.txt,sha256=fTkAtiFuL16nUrB9ytDDtpytz2t0B4NvYTnRzwAhO14,7 diff --git a/lib/python3.12/site-packages/nvidia_nccl_cu12-2.21.5.dist-info/REQUESTED b/lib/python3.12/site-packages/nvidia_nccl_cu12-2.21.5.dist-info/REQUESTED new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lib/python3.12/site-packages/nvidia_nccl_cu12-2.21.5.dist-info/WHEEL b/lib/python3.12/site-packages/nvidia_nccl_cu12-2.21.5.dist-info/WHEEL new file mode 100644 index 0000000000000000000000000000000000000000..e6c30e957cfb045017a9fef3430bb8ee87c4a074 --- /dev/null +++ b/lib/python3.12/site-packages/nvidia_nccl_cu12-2.21.5.dist-info/WHEEL @@ -0,0 +1,5 @@ +Wheel-Version: 1.0 +Generator: bdist_wheel (0.42.0) +Root-Is-Purelib: true +Tag: py3-none-manylinux2014_x86_64 + diff --git a/lib/python3.12/site-packages/nvidia_nccl_cu12-2.21.5.dist-info/top_level.txt b/lib/python3.12/site-packages/nvidia_nccl_cu12-2.21.5.dist-info/top_level.txt new file mode 100644 index 0000000000000000000000000000000000000000..862f7abf232cdfbb928609856247292e81c9decb --- /dev/null +++ b/lib/python3.12/site-packages/nvidia_nccl_cu12-2.21.5.dist-info/top_level.txt @@ -0,0 +1 @@ +nvidia diff --git a/lib/python3.12/site-packages/platformdirs-4.5.1.dist-info/INSTALLER b/lib/python3.12/site-packages/platformdirs-4.5.1.dist-info/INSTALLER new file mode 100644 index 0000000000000000000000000000000000000000..a1b589e38a32041e49332e5e81c2d363dc418d68 --- /dev/null +++ b/lib/python3.12/site-packages/platformdirs-4.5.1.dist-info/INSTALLER @@ -0,0 +1 @@ +pip diff --git a/lib/python3.12/site-packages/platformdirs-4.5.1.dist-info/METADATA b/lib/python3.12/site-packages/platformdirs-4.5.1.dist-info/METADATA new file mode 100644 index 0000000000000000000000000000000000000000..e7a4602a05fd85f92a388640d19072799a0e5317 --- /dev/null +++ b/lib/python3.12/site-packages/platformdirs-4.5.1.dist-info/METADATA @@ -0,0 +1,350 @@ +Metadata-Version: 2.4 +Name: platformdirs +Version: 4.5.1 +Summary: A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`. +Project-URL: Changelog, https://github.com/tox-dev/platformdirs/releases +Project-URL: Documentation, https://platformdirs.readthedocs.io +Project-URL: Homepage, https://github.com/tox-dev/platformdirs +Project-URL: Source, https://github.com/tox-dev/platformdirs +Project-URL: Tracker, https://github.com/tox-dev/platformdirs/issues +Maintainer-email: Bernát Gábor <gaborjbernat@gmail.com>, Julian Berman <Julian@GrayVines.com>, Ofek Lev <oss@ofek.dev>, Ronny Pfannschmidt <opensource@ronnypfannschmidt.de> +License-Expression: MIT +License-File: LICENSE +Keywords: appdirs,application,cache,directory,log,user +Classifier: Development Status :: 5 - Production/Stable +Classifier: Intended Audience :: Developers +Classifier: License :: OSI Approved :: MIT License +Classifier: Operating System :: OS Independent +Classifier: Programming Language :: Python +Classifier: Programming Language :: Python :: 3 :: Only +Classifier: Programming Language :: Python :: 3.10 +Classifier: Programming Language :: Python :: 3.11 +Classifier: Programming Language :: Python :: 3.12 +Classifier: Programming Language :: Python :: 3.13 +Classifier: Programming Language :: Python :: 3.14 +Classifier: Programming Language :: Python :: Implementation :: CPython +Classifier: Programming Language :: Python :: Implementation :: PyPy +Classifier: Topic :: Software Development :: Libraries :: Python Modules +Requires-Python: >=3.10 +Provides-Extra: docs +Requires-Dist: furo>=2025.9.25; extra == 'docs' +Requires-Dist: proselint>=0.14; extra == 'docs' +Requires-Dist: sphinx-autodoc-typehints>=3.2; extra == 'docs' +Requires-Dist: sphinx>=8.2.3; extra == 'docs' +Provides-Extra: test +Requires-Dist: appdirs==1.4.4; extra == 'test' +Requires-Dist: covdefaults>=2.3; extra == 'test' +Requires-Dist: pytest-cov>=7; extra == 'test' +Requires-Dist: pytest-mock>=3.15.1; extra == 'test' +Requires-Dist: pytest>=8.4.2; extra == 'test' +Provides-Extra: type +Requires-Dist: mypy>=1.18.2; extra == 'type' +Description-Content-Type: text/x-rst + +The problem +=========== + +.. image:: https://badge.fury.io/py/platformdirs.svg + :target: https://badge.fury.io/py/platformdirs +.. image:: https://img.shields.io/pypi/pyversions/platformdirs.svg + :target: https://pypi.python.org/pypi/platformdirs/ +.. image:: https://github.com/tox-dev/platformdirs/actions/workflows/check.yaml/badge.svg + :target: https://github.com/platformdirs/platformdirs/actions +.. image:: https://static.pepy.tech/badge/platformdirs/month + :target: https://pepy.tech/project/platformdirs + +When writing desktop application, finding the right location to store user data +and configuration varies per platform. Even for single-platform apps, there +may by plenty of nuances in figuring out the right location. + +For example, if running on macOS, you should use:: + + ~/Library/Application Support/<AppName> + +If on Windows (at least English Win) that should be:: + + C:\Users\<User>\Application Data\Local Settings\<AppAuthor>\<AppName> + +or possibly:: + + C:\Users\<User>\Application Data\<AppAuthor>\<AppName> + +for `roaming profiles <https://docs.microsoft.com/en-us/previous-versions/windows/it-pro/windows-vista/cc766489(v=ws.10)>`_ but that is another story. + +On Linux (and other Unices), according to the `XDG Basedir Spec`_, it should be:: + + ~/.local/share/<AppName> + +.. _XDG Basedir Spec: https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html + +``platformdirs`` to the rescue +============================== + +This kind of thing is what the ``platformdirs`` package is for. +``platformdirs`` will help you choose an appropriate: + +- user data dir (``user_data_dir``) +- user config dir (``user_config_dir``) +- user cache dir (``user_cache_dir``) +- site data dir (``site_data_dir``) +- site config dir (``site_config_dir``) +- user log dir (``user_log_dir``) +- user documents dir (``user_documents_dir``) +- user downloads dir (``user_downloads_dir``) +- user pictures dir (``user_pictures_dir``) +- user videos dir (``user_videos_dir``) +- user music dir (``user_music_dir``) +- user desktop dir (``user_desktop_dir``) +- user runtime dir (``user_runtime_dir``) + +And also: + +- Is slightly opinionated on the directory names used. Look for "OPINION" in + documentation and code for when an opinion is being applied. + +Example output +============== + +On macOS: + +.. code-block:: pycon + + >>> from platformdirs import * + >>> appname = "SuperApp" + >>> appauthor = "Acme" + >>> user_data_dir(appname, appauthor) + '/Users/trentm/Library/Application Support/SuperApp' + >>> user_config_dir(appname, appauthor) + '/Users/trentm/Library/Application Support/SuperApp' + >>> user_cache_dir(appname, appauthor) + '/Users/trentm/Library/Caches/SuperApp' + >>> site_data_dir(appname, appauthor) + '/Library/Application Support/SuperApp' + >>> site_config_dir(appname, appauthor) + '/Library/Application Support/SuperApp' + >>> user_log_dir(appname, appauthor) + '/Users/trentm/Library/Logs/SuperApp' + >>> user_documents_dir() + '/Users/trentm/Documents' + >>> user_downloads_dir() + '/Users/trentm/Downloads' + >>> user_pictures_dir() + '/Users/trentm/Pictures' + >>> user_videos_dir() + '/Users/trentm/Movies' + >>> user_music_dir() + '/Users/trentm/Music' + >>> user_desktop_dir() + '/Users/trentm/Desktop' + >>> user_runtime_dir(appname, appauthor) + '/Users/trentm/Library/Caches/TemporaryItems/SuperApp' + +On Windows: + +.. code-block:: pycon + + >>> from platformdirs import * + >>> appname = "SuperApp" + >>> appauthor = "Acme" + >>> user_data_dir(appname, appauthor) + 'C:\\Users\\trentm\\AppData\\Local\\Acme\\SuperApp' + >>> user_data_dir(appname, appauthor, roaming=True) + 'C:\\Users\\trentm\\AppData\\Roaming\\Acme\\SuperApp' + >>> user_config_dir(appname, appauthor) + 'C:\\Users\\trentm\\AppData\\Local\\Acme\\SuperApp' + >>> user_cache_dir(appname, appauthor) + 'C:\\Users\\trentm\\AppData\\Local\\Acme\\SuperApp\\Cache' + >>> site_data_dir(appname, appauthor) + 'C:\\ProgramData\\Acme\\SuperApp' + >>> site_config_dir(appname, appauthor) + 'C:\\ProgramData\\Acme\\SuperApp' + >>> user_log_dir(appname, appauthor) + 'C:\\Users\\trentm\\AppData\\Local\\Acme\\SuperApp\\Logs' + >>> user_documents_dir() + 'C:\\Users\\trentm\\Documents' + >>> user_downloads_dir() + 'C:\\Users\\trentm\\Downloads' + >>> user_pictures_dir() + 'C:\\Users\\trentm\\Pictures' + >>> user_videos_dir() + 'C:\\Users\\trentm\\Videos' + >>> user_music_dir() + 'C:\\Users\\trentm\\Music' + >>> user_desktop_dir() + 'C:\\Users\\trentm\\Desktop' + >>> user_runtime_dir(appname, appauthor) + 'C:\\Users\\trentm\\AppData\\Local\\Temp\\Acme\\SuperApp' + +On Linux: + +.. code-block:: pycon + + >>> from platformdirs import * + >>> appname = "SuperApp" + >>> appauthor = "Acme" + >>> user_data_dir(appname, appauthor) + '/home/trentm/.local/share/SuperApp' + >>> user_config_dir(appname) + '/home/trentm/.config/SuperApp' + >>> user_cache_dir(appname, appauthor) + '/home/trentm/.cache/SuperApp' + >>> site_data_dir(appname, appauthor) + '/usr/local/share/SuperApp' + >>> site_data_dir(appname, appauthor, multipath=True) + '/usr/local/share/SuperApp:/usr/share/SuperApp' + >>> site_config_dir(appname) + '/etc/xdg/SuperApp' + >>> os.environ["XDG_CONFIG_DIRS"] = "/etc:/usr/local/etc" + >>> site_config_dir(appname, multipath=True) + '/etc/SuperApp:/usr/local/etc/SuperApp' + >>> user_log_dir(appname, appauthor) + '/home/trentm/.local/state/SuperApp/log' + >>> user_documents_dir() + '/home/trentm/Documents' + >>> user_downloads_dir() + '/home/trentm/Downloads' + >>> user_pictures_dir() + '/home/trentm/Pictures' + >>> user_videos_dir() + '/home/trentm/Videos' + >>> user_music_dir() + '/home/trentm/Music' + >>> user_desktop_dir() + '/home/trentm/Desktop' + >>> user_runtime_dir(appname, appauthor) + '/run/user/{os.getuid()}/SuperApp' + +On Android:: + + >>> from platformdirs import * + >>> appname = "SuperApp" + >>> appauthor = "Acme" + >>> user_data_dir(appname, appauthor) + '/data/data/com.myApp/files/SuperApp' + >>> user_config_dir(appname) + '/data/data/com.myApp/shared_prefs/SuperApp' + >>> user_cache_dir(appname, appauthor) + '/data/data/com.myApp/cache/SuperApp' + >>> site_data_dir(appname, appauthor) + '/data/data/com.myApp/files/SuperApp' + >>> site_config_dir(appname) + '/data/data/com.myApp/shared_prefs/SuperApp' + >>> user_log_dir(appname, appauthor) + '/data/data/com.myApp/cache/SuperApp/log' + >>> user_documents_dir() + '/storage/emulated/0/Documents' + >>> user_downloads_dir() + '/storage/emulated/0/Downloads' + >>> user_pictures_dir() + '/storage/emulated/0/Pictures' + >>> user_videos_dir() + '/storage/emulated/0/DCIM/Camera' + >>> user_music_dir() + '/storage/emulated/0/Music' + >>> user_desktop_dir() + '/storage/emulated/0/Desktop' + >>> user_runtime_dir(appname, appauthor) + '/data/data/com.myApp/cache/SuperApp/tmp' + +Note: Some android apps like Termux and Pydroid are used as shells. These +apps are used by the end user to emulate Linux environment. Presence of +``SHELL`` environment variable is used by Platformdirs to differentiate +between general android apps and android apps used as shells. Shell android +apps also support ``XDG_*`` environment variables. + + +``PlatformDirs`` for convenience +================================ + +.. code-block:: pycon + + >>> from platformdirs import PlatformDirs + >>> dirs = PlatformDirs("SuperApp", "Acme") + >>> dirs.user_data_dir + '/Users/trentm/Library/Application Support/SuperApp' + >>> dirs.user_config_dir + '/Users/trentm/Library/Application Support/SuperApp' + >>> dirs.user_cache_dir + '/Users/trentm/Library/Caches/SuperApp' + >>> dirs.site_data_dir + '/Library/Application Support/SuperApp' + >>> dirs.site_config_dir + '/Library/Application Support/SuperApp' + >>> dirs.user_cache_dir + '/Users/trentm/Library/Caches/SuperApp' + >>> dirs.user_log_dir + '/Users/trentm/Library/Logs/SuperApp' + >>> dirs.user_documents_dir + '/Users/trentm/Documents' + >>> dirs.user_downloads_dir + '/Users/trentm/Downloads' + >>> dirs.user_pictures_dir + '/Users/trentm/Pictures' + >>> dirs.user_videos_dir + '/Users/trentm/Movies' + >>> dirs.user_music_dir + '/Users/trentm/Music' + >>> dirs.user_desktop_dir + '/Users/trentm/Desktop' + >>> dirs.user_runtime_dir + '/Users/trentm/Library/Caches/TemporaryItems/SuperApp' + +Per-version isolation +===================== + +If you have multiple versions of your app in use that you want to be +able to run side-by-side, then you may want version-isolation for these +dirs:: + + >>> from platformdirs import PlatformDirs + >>> dirs = PlatformDirs("SuperApp", "Acme", version="1.0") + >>> dirs.user_data_dir + '/Users/trentm/Library/Application Support/SuperApp/1.0' + >>> dirs.user_config_dir + '/Users/trentm/Library/Application Support/SuperApp/1.0' + >>> dirs.user_cache_dir + '/Users/trentm/Library/Caches/SuperApp/1.0' + >>> dirs.site_data_dir + '/Library/Application Support/SuperApp/1.0' + >>> dirs.site_config_dir + '/Library/Application Support/SuperApp/1.0' + >>> dirs.user_log_dir + '/Users/trentm/Library/Logs/SuperApp/1.0' + >>> dirs.user_documents_dir + '/Users/trentm/Documents' + >>> dirs.user_downloads_dir + '/Users/trentm/Downloads' + >>> dirs.user_pictures_dir + '/Users/trentm/Pictures' + >>> dirs.user_videos_dir + '/Users/trentm/Movies' + >>> dirs.user_music_dir + '/Users/trentm/Music' + >>> dirs.user_desktop_dir + '/Users/trentm/Desktop' + >>> dirs.user_runtime_dir + '/Users/trentm/Library/Caches/TemporaryItems/SuperApp/1.0' + +Be wary of using this for configuration files though; you'll need to handle +migrating configuration files manually. + +Why this Fork? +============== + +This repository is a friendly fork of the wonderful work started by +`ActiveState <https://github.com/ActiveState/appdirs>`_ who created +``appdirs``, this package's ancestor. + +Maintaining an open source project is no easy task, particularly +from within an organization, and the Python community is indebted +to ``appdirs`` (and to Trent Mick and Jeff Rouse in particular) for +creating an incredibly useful simple module, as evidenced by the wide +number of users it has attracted over the years. + +Nonetheless, given the number of long-standing open issues +and pull requests, and no clear path towards `ensuring +that maintenance of the package would continue or grow +<https://github.com/ActiveState/appdirs/issues/79>`_, this fork was +created. + +Contributions are most welcome. diff --git a/lib/python3.12/site-packages/platformdirs-4.5.1.dist-info/RECORD b/lib/python3.12/site-packages/platformdirs-4.5.1.dist-info/RECORD new file mode 100644 index 0000000000000000000000000000000000000000..07a9922db209ac6a6f4d3bfeda2138d308839778 --- /dev/null +++ b/lib/python3.12/site-packages/platformdirs-4.5.1.dist-info/RECORD @@ -0,0 +1,22 @@ +platformdirs-4.5.1.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4 +platformdirs-4.5.1.dist-info/METADATA,sha256=On75DGlONGSf7OEdJ-iGIc1rwTREl-PjHlbUa9Qj6Sg,12804 +platformdirs-4.5.1.dist-info/RECORD,, +platformdirs-4.5.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87 +platformdirs-4.5.1.dist-info/licenses/LICENSE,sha256=KeD9YukphQ6G6yjD_czwzv30-pSHkBHP-z0NS-1tTbY,1089 +platformdirs/__init__.py,sha256=iORRy6_lZ9tXLvO0W6fJPn8QV7F532ivl-f2WGmabBc,22284 +platformdirs/__main__.py,sha256=HnsUQHpiBaiTxwcmwVw-nFaPdVNZtQIdi1eWDtI-MzI,1493 +platformdirs/__pycache__/__init__.cpython-312.pyc,, +platformdirs/__pycache__/__main__.cpython-312.pyc,, +platformdirs/__pycache__/android.cpython-312.pyc,, +platformdirs/__pycache__/api.cpython-312.pyc,, +platformdirs/__pycache__/macos.cpython-312.pyc,, +platformdirs/__pycache__/unix.cpython-312.pyc,, +platformdirs/__pycache__/version.cpython-312.pyc,, +platformdirs/__pycache__/windows.cpython-312.pyc,, +platformdirs/android.py,sha256=r0DshVBf-RO1jXJGX8C4Til7F1XWt-bkdWMgmvEiaYg,9013 +platformdirs/api.py,sha256=wPHOlwOsfz2oqQZ6A2FcCu5kEAj-JondzoNOHYFQ0h8,9281 +platformdirs/macos.py,sha256=0XoOgin1NK7Qki7iskD-oS8xKxw6bXgoKEgdqpCRAFQ,6322 +platformdirs/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +platformdirs/unix.py,sha256=WZmkUA--L3JNRGmz32s35YfoD3ica6xKIPdCV_HhLcs,10458 +platformdirs/version.py,sha256=BI_dKLSMwlkl57vlxZnT8oVjPiUC2W_sdx_8_h99HeQ,704 +platformdirs/windows.py,sha256=XvCfklGUMVxJbXit51jpYMN-lNeScPB82qS1CAeplL0,10362 diff --git a/lib/python3.12/site-packages/platformdirs-4.5.1.dist-info/WHEEL b/lib/python3.12/site-packages/platformdirs-4.5.1.dist-info/WHEEL new file mode 100644 index 0000000000000000000000000000000000000000..ae8ec1bdaa94d726ceb907542d76cbd5d38cafcd --- /dev/null +++ b/lib/python3.12/site-packages/platformdirs-4.5.1.dist-info/WHEEL @@ -0,0 +1,4 @@ +Wheel-Version: 1.0 +Generator: hatchling 1.28.0 +Root-Is-Purelib: true +Tag: py3-none-any diff --git a/lib/python3.12/site-packages/platformdirs-4.5.1.dist-info/licenses/LICENSE b/lib/python3.12/site-packages/platformdirs-4.5.1.dist-info/licenses/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..f35fed9191b1142ddaada8a96de4a9461c5d796c --- /dev/null +++ b/lib/python3.12/site-packages/platformdirs-4.5.1.dist-info/licenses/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2010-202x The platformdirs developers + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/lib/python3.12/site-packages/ray/runtime_env/__init__.py b/lib/python3.12/site-packages/ray/runtime_env/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..f3cd30f708d0f9cdd28b0ed1295013694f26f93b --- /dev/null +++ b/lib/python3.12/site-packages/ray/runtime_env/__init__.py @@ -0,0 +1,6 @@ +from ray.runtime_env.runtime_env import RuntimeEnv, RuntimeEnvConfig # noqa: E402,F401 + +__all__ = [ + "RuntimeEnvConfig", + "RuntimeEnv", +] diff --git a/lib/python3.12/site-packages/ray/runtime_env/__pycache__/__init__.cpython-312.pyc b/lib/python3.12/site-packages/ray/runtime_env/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f30c0232824532d272d27dc0908d51ec78e85124 Binary files /dev/null and b/lib/python3.12/site-packages/ray/runtime_env/__pycache__/__init__.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/runtime_env/__pycache__/runtime_env.cpython-312.pyc b/lib/python3.12/site-packages/ray/runtime_env/__pycache__/runtime_env.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fc8aef695f9d6e2d4563bc0a58f7fa2fb6817054 Binary files /dev/null and b/lib/python3.12/site-packages/ray/runtime_env/__pycache__/runtime_env.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/runtime_env/runtime_env.py b/lib/python3.12/site-packages/ray/runtime_env/runtime_env.py new file mode 100644 index 0000000000000000000000000000000000000000..2b7d5d9be5f2ee52516b38176ffb2bac25924b1d --- /dev/null +++ b/lib/python3.12/site-packages/ray/runtime_env/runtime_env.py @@ -0,0 +1,679 @@ +import json +import logging +import os +from copy import deepcopy +from dataclasses import asdict, is_dataclass +from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Union + +import ray +from ray._private.ray_constants import DEFAULT_RUNTIME_ENV_TIMEOUT_SECONDS +from ray._private.runtime_env.conda import get_uri as get_conda_uri +from ray._private.runtime_env.default_impl import get_image_uri_plugin_cls +from ray._private.runtime_env.pip import get_uri as get_pip_uri +from ray._private.runtime_env.plugin_schema_manager import RuntimeEnvPluginSchemaManager +from ray._private.runtime_env.uv import get_uri as get_uv_uri +from ray._private.runtime_env.validation import ( + OPTION_TO_NO_PATH_VALIDATION_FN, + OPTION_TO_VALIDATION_FN, +) +from ray._private.thirdparty.dacite import from_dict +from ray.core.generated.runtime_environment_pb2 import ( + RuntimeEnvConfig as ProtoRuntimeEnvConfig, +) +from ray.util.annotations import PublicAPI + +logger = logging.getLogger(__name__) + + +@PublicAPI(stability="stable") +class RuntimeEnvConfig(dict): + """Used to specify configuration options for a runtime environment. + + The config is not included when calculating the runtime_env hash, + which means that two runtime_envs with the same options but different + configs are considered the same for caching purposes. + + Args: + setup_timeout_seconds: The timeout of runtime environment + creation, timeout is in seconds. The value `-1` means disable + timeout logic, except `-1`, `setup_timeout_seconds` cannot be + less than or equal to 0. The default value of `setup_timeout_seconds` + is 600 seconds. + eager_install: Indicates whether to install the runtime environment + on the cluster at `ray.init()` time, before the workers are leased. + This flag is set to `True` by default. + """ + + known_fields: Set[str] = {"setup_timeout_seconds", "eager_install", "log_files"} + + _default_config: Dict = { + "setup_timeout_seconds": DEFAULT_RUNTIME_ENV_TIMEOUT_SECONDS, + "eager_install": True, + "log_files": [], + } + + def __init__( + self, + setup_timeout_seconds: int = DEFAULT_RUNTIME_ENV_TIMEOUT_SECONDS, + eager_install: bool = True, + log_files: Optional[List[str]] = None, + ): + super().__init__() + if not isinstance(setup_timeout_seconds, int): + raise TypeError( + "setup_timeout_seconds must be of type int, " + f"got: {type(setup_timeout_seconds)}" + ) + elif setup_timeout_seconds <= 0 and setup_timeout_seconds != -1: + raise ValueError( + "setup_timeout_seconds must be greater than zero " + f"or equals to -1, got: {setup_timeout_seconds}" + ) + self["setup_timeout_seconds"] = setup_timeout_seconds + + if not isinstance(eager_install, bool): + raise TypeError( + f"eager_install must be a boolean. got {type(eager_install)}" + ) + self["eager_install"] = eager_install + + if log_files is not None: + if not isinstance(log_files, list): + raise TypeError( + "log_files must be a list of strings or None, got " + f"{log_files} with type {type(log_files)}." + ) + for file_name in log_files: + if not isinstance(file_name, str): + raise TypeError("Each item in log_files must be a string.") + else: + log_files = self._default_config["log_files"] + + self["log_files"] = log_files + + @staticmethod + def parse_and_validate_runtime_env_config( + config: Union[Dict, "RuntimeEnvConfig"] + ) -> "RuntimeEnvConfig": + if isinstance(config, RuntimeEnvConfig): + return config + elif isinstance(config, Dict): + unknown_fields = set(config.keys()) - RuntimeEnvConfig.known_fields + if len(unknown_fields): + logger.warning( + "The following unknown entries in the runtime_env_config " + f"dictionary will be ignored: {unknown_fields}." + ) + config_dict = dict() + for field in RuntimeEnvConfig.known_fields: + if field in config: + config_dict[field] = config[field] + return RuntimeEnvConfig(**config_dict) + else: + raise TypeError( + "runtime_env['config'] must be of type dict or RuntimeEnvConfig, " + f"got: {type(config)}" + ) + + @classmethod + def default_config(cls): + return RuntimeEnvConfig(**cls._default_config) + + def build_proto_runtime_env_config(self) -> ProtoRuntimeEnvConfig: + runtime_env_config = ProtoRuntimeEnvConfig() + runtime_env_config.setup_timeout_seconds = self["setup_timeout_seconds"] + runtime_env_config.eager_install = self["eager_install"] + if self["log_files"] is not None: + runtime_env_config.log_files.extend(self["log_files"]) + return runtime_env_config + + @classmethod + def from_proto(cls, runtime_env_config: ProtoRuntimeEnvConfig): + setup_timeout_seconds = runtime_env_config.setup_timeout_seconds + # Cause python class RuntimeEnvConfig has validate to avoid + # setup_timeout_seconds equals zero, so setup_timeout_seconds + # on RuntimeEnvConfig is zero means other Language(except python) + # dosn't assign value to setup_timeout_seconds. So runtime_env_agent + # assign the default value to setup_timeout_seconds. + if setup_timeout_seconds == 0: + setup_timeout_seconds = cls._default_config["setup_timeout_seconds"] + return cls( + setup_timeout_seconds=setup_timeout_seconds, + eager_install=runtime_env_config.eager_install, + log_files=list(runtime_env_config.log_files), + ) + + def to_dict(self) -> Dict: + return dict(deepcopy(self)) + + +# Due to circular reference, field config can only be assigned a value here +OPTION_TO_VALIDATION_FN[ + "config" +] = RuntimeEnvConfig.parse_and_validate_runtime_env_config + + +@PublicAPI +class RuntimeEnv(dict): + """This class is used to define a runtime environment for a job, task, + or actor. + + See :ref:`runtime-environments` for detailed documentation. + + This class can be used interchangeably with an unstructured dictionary + in the relevant API calls. + + Can specify a runtime environment whole job, whether running a script + directly on the cluster, using Ray Job submission, or using Ray Client: + + .. code-block:: python + + from ray.runtime_env import RuntimeEnv + # Starting a single-node local Ray cluster + ray.init(runtime_env=RuntimeEnv(...)) + + .. code-block:: python + + from ray.runtime_env import RuntimeEnv + # Connecting to remote cluster using Ray Client + ray.init("ray://123.456.7.89:10001", runtime_env=RuntimeEnv(...)) + + Can specify different runtime environments per-actor or per-task using + ``.options()`` or the ``@ray.remote`` decorator: + + .. code-block:: python + + from ray.runtime_env import RuntimeEnv + # Invoke a remote task that runs in a specified runtime environment. + f.options(runtime_env=RuntimeEnv(...)).remote() + + # Instantiate an actor that runs in a specified runtime environment. + actor = SomeClass.options(runtime_env=RuntimeEnv(...)).remote() + + # Specify a runtime environment in the task definition. Future invocations via + # `g.remote()` use this runtime environment unless overridden by using + # `.options()` as above. + @ray.remote(runtime_env=RuntimeEnv(...)) + def g(): + pass + + # Specify a runtime environment in the actor definition. Future instantiations + # via `MyClass.remote()` use this runtime environment unless overridden by + # using `.options()` as above. + @ray.remote(runtime_env=RuntimeEnv(...)) + class MyClass: + pass + + Here are some examples of RuntimeEnv initialization: + + .. code-block:: python + + # Example for using conda + RuntimeEnv(conda={ + "channels": ["defaults"], "dependencies": ["codecov"]}) + RuntimeEnv(conda="pytorch_p36") # Found on DLAMIs + + # Example for using container + RuntimeEnv( + container={"image": "anyscale/ray-ml:nightly-py38-cpu", + "run_options": ["--cap-drop SYS_ADMIN","--log-level=debug"]}) + + # Example for set env_vars + RuntimeEnv(env_vars={"OMP_NUM_THREADS": "32", "TF_WARNINGS": "none"}) + + # Example for set pip + RuntimeEnv( + pip={"packages":["tensorflow", "requests"], "pip_check": False, + "pip_version": "==22.0.2;python_version=='3.8.11'"}) + + # Example for using image_uri + RuntimeEnv( + image_uri="rayproject/ray:2.39.0-py312-cu123") + + Args: + py_modules: List of local paths or remote URIs (either in the GCS or external + storage), each of which is a zip file that Ray unpacks and + inserts into the PYTHONPATH of the workers. + working_dir: Local path or remote URI (either in the GCS or external storage) of a zip + file that Ray unpacks in the directory of each task/actor. + pip: Either a list of pip packages, a string + containing the path to a pip requirements.txt file, or a Python + dictionary that has three fields: 1) ``packages`` (required, List[str]): a + list of pip packages, 2) ``pip_check`` (optional, bool): whether enable + pip check at the end of pip install, defaults to False. + 3) ``pip_version`` (optional, str): the version of pip, Ray prepends + the package name "pip" in front of the ``pip_version`` to form the final + requirement string, the syntax of a requirement specifier is defined in + full in PEP 508. + uv: Either a list of pip packages, or a Python dictionary that has one field: + 1) ``packages`` (required, List[str]). + conda: Either the conda YAML config, the name of a + local conda env (e.g., "pytorch_p36"), or the path to a conda + environment.yaml file. + Ray automatically injects the dependency into the conda + env to ensure compatibility with the cluster Ray. Ray may automatically + mangle the conda name to avoid conflicts between runtime envs. + This field can't be specified at the same time as the 'pip' field. + To use pip with conda, specify your pip dependencies within + the conda YAML config: + https://conda.io/projects/conda/en/latest/user-guide/tasks/manage-environments.html#create-env-file-manually + container: Require a given (Docker) container image, + The Ray worker process runs in a container with this image. + This parameter only works alone, or with the ``config`` or + ``env_vars`` parameters. + The `run_options` list spec is here: + https://docs.docker.com/engine/reference/run/ + env_vars: Environment variables to set. + worker_process_setup_hook: (Experimental) The setup hook that's + called after workers start and before Tasks and Actors are scheduled. + A module name (string type) or callable (function) can be passed. + When a module name is passed, Ray worker should be able to access the + module name. When a callable is passed, callable should be serializable. + When a runtime env is specified by job submission API, + only a module name (string) is allowed. + nsight: Dictionary mapping nsight profile option name to it's value. + rocprof_sys: Dictionary mapping rocprof-sys profile option name and environment + variables to it's value. + config: config for runtime environment. Either + a dict or a RuntimeEnvConfig. Field: (1) setup_timeout_seconds, the + timeout of runtime environment creation, timeout is in seconds. + image_uri: URI to a container image. The Ray worker process runs + in a container with this image. This parameter only works alone, + or with the ``config`` or ``env_vars`` parameters. + """ + + known_fields: Set[str] = { + "py_modules", + "py_executable", + "java_jars", + "working_dir", + "conda", + "pip", + "uv", + "container", + "excludes", + "env_vars", + "_ray_release", + "_ray_commit", + "_inject_current_ray", + "config", + "worker_process_setup_hook", + "_nsight", + "_rocprof_sys", + "image_uri", + } + + extensions_fields: Set[str] = { + "_ray_release", + "_ray_commit", + "_inject_current_ray", + } + + def __init__( + self, + *, + py_modules: Optional[List[str]] = None, + py_executable: Optional[str] = None, + working_dir: Optional[str] = None, + pip: Optional[List[str]] = None, + conda: Optional[Union[Dict[str, str], str]] = None, + container: Optional[Dict[str, str]] = None, + env_vars: Optional[Dict[str, str]] = None, + worker_process_setup_hook: Optional[Union[Callable, str]] = None, + nsight: Optional[Union[str, Dict[str, str]]] = None, + rocprof_sys: Optional[Union[str, Dict[str, Dict[str, str]]]] = None, + config: Optional[Union[Dict, RuntimeEnvConfig]] = None, + _validate: bool = True, + image_uri: Optional[str] = None, + uv: Optional[List[str]] = None, + **kwargs, + ): + super().__init__() + + runtime_env = kwargs + if py_modules is not None: + runtime_env["py_modules"] = py_modules + if py_executable is not None: + runtime_env["py_executable"] = py_executable + if working_dir is not None: + runtime_env["working_dir"] = working_dir + if pip is not None: + runtime_env["pip"] = pip + if uv is not None: + runtime_env["uv"] = uv + if conda is not None: + runtime_env["conda"] = conda + if nsight is not None: + runtime_env["_nsight"] = nsight + if rocprof_sys is not None: + runtime_env["_rocprof_sys"] = rocprof_sys + if container is not None: + runtime_env["container"] = container + if env_vars is not None: + runtime_env["env_vars"] = env_vars + if config is not None: + runtime_env["config"] = config + if worker_process_setup_hook is not None: + runtime_env["worker_process_setup_hook"] = worker_process_setup_hook + if image_uri is not None: + runtime_env["image_uri"] = image_uri + + self.update(runtime_env) + + # Blindly trust that the runtime_env has already been validated. + # This is dangerous and should only be used internally (e.g., on the + # deserialization codepath. + if not _validate: + return + + if (self.get("conda") is not None) + (self.get("pip") is not None) + ( + self.get("uv") is not None + ) > 1: + raise ValueError( + "The 'pip' field, 'uv' field, and 'conda' field of " + "runtime_env cannot be specified at the same time.\n" + f"specified pip field: {self.get('pip')}\n" + f"specified conda field: {self.get('conda')}\n" + f"specified uv field: {self.get('uv')}\n" + "To use pip with conda, please only set the 'conda'" + "field, and specify your pip dependencies within the conda YAML " + "config dict: see https://conda.io/projects/conda/en/latest/" + "user-guide/tasks/manage-environments.html" + "#create-env-file-manually" + ) + + if self.get("container"): + invalid_keys = set(runtime_env.keys()) - {"container", "config", "env_vars"} + if len(invalid_keys): + raise ValueError( + "The 'container' field currently cannot be used " + "together with other fields of runtime_env. " + f"Specified fields: {invalid_keys}" + ) + + logger.warning( + "The `container` runtime environment field is DEPRECATED and will be " + "removed after July 31, 2025. Use `image_uri` instead. See " + "https://docs.ray.io/en/latest/serve/advanced-guides/multi-app-container.html." # noqa + ) + + if self.get("image_uri"): + image_uri_plugin_cls = get_image_uri_plugin_cls() + invalid_keys = ( + set(runtime_env.keys()) - image_uri_plugin_cls.get_compatible_keys() + ) + if len(invalid_keys): + raise ValueError( + "The 'image_uri' field currently cannot be used " + "together with other fields of runtime_env. " + f"Specified fields: {invalid_keys}" + ) + + for option, validate_fn in OPTION_TO_VALIDATION_FN.items(): + option_val = self.get(option) + if option_val is not None: + del self[option] + self[option] = option_val + + if "_ray_commit" not in self: + if self.get("pip") or self.get("conda"): + self["_ray_commit"] = ray.__commit__ + + # Used for testing wheels that have not yet been merged into master. + # If this is set to True, then we do not inject Ray into the conda + # or pip dependencies. + if "_inject_current_ray" not in self: + if "RAY_RUNTIME_ENV_LOCAL_DEV_MODE" in os.environ: + self["_inject_current_ray"] = True + + # NOTE(architkulkarni): This allows worker caching code in C++ to check + # if a runtime env is empty without deserializing it. This is a catch- + # all; for validated inputs we won't set the key if the value is None. + if all(val is None for val in self.values()): + self.clear() + + def __setitem__(self, key: str, value: Any) -> None: + if is_dataclass(value): + jsonable_type = asdict(value) + else: + jsonable_type = value + RuntimeEnvPluginSchemaManager.validate(key, jsonable_type) + res_value = jsonable_type + if key in RuntimeEnv.known_fields and key in OPTION_TO_VALIDATION_FN: + res_value = OPTION_TO_VALIDATION_FN[key](jsonable_type) + if res_value is None: + return + return super().__setitem__(key, res_value) + + def set(self, name: str, value: Any) -> None: + self.__setitem__(name, value) + + def get(self, name, default=None, data_class=None): + if name not in self: + return default + if not data_class: + return self.__getitem__(name) + else: + return from_dict(data_class=data_class, data=self.__getitem__(name)) + + @classmethod + def deserialize(cls, serialized_runtime_env: str) -> "RuntimeEnv": # noqa: F821 + return cls(_validate=False, **json.loads(serialized_runtime_env)) + + def serialize(self) -> str: + # To ensure the accuracy of Proto, `__setitem__` can only guarantee the + # accuracy of a certain field, not the overall accuracy + runtime_env = type(self)(_validate=True, **self) + return json.dumps( + runtime_env, + sort_keys=True, + ) + + def to_dict(self) -> Dict: + runtime_env_dict = dict(deepcopy(self)) + + # Replace strongly-typed RuntimeEnvConfig with a dict to allow the returned + # dict to work properly as a field in a dataclass. Details in issue #26986 + if runtime_env_dict.get("config"): + runtime_env_dict["config"] = runtime_env_dict["config"].to_dict() + + return runtime_env_dict + + def has_working_dir(self) -> bool: + return self.get("working_dir") is not None + + def working_dir_uri(self) -> Optional[str]: + return self.get("working_dir") + + def py_modules_uris(self) -> List[str]: + if "py_modules" in self: + return list(self["py_modules"]) + return [] + + def conda_uri(self) -> Optional[str]: + if "conda" in self: + return get_conda_uri(self) + return None + + def pip_uri(self) -> Optional[str]: + if "pip" in self: + return get_pip_uri(self) + return None + + def uv_uri(self) -> Optional[str]: + if "uv" in self: + return get_uv_uri(self) + return None + + def plugin_uris(self) -> List[str]: + """Not implemented yet, always return a empty list""" + return [] + + def working_dir(self) -> str: + return self.get("working_dir", "") + + def py_modules(self) -> List[str]: + if "py_modules" in self: + return list(self["py_modules"]) + return [] + + def py_executable(self) -> Optional[str]: + return self.get("py_executable", None) + + def java_jars(self) -> List[str]: + if "java_jars" in self: + return list(self["java_jars"]) + return [] + + def nsight(self) -> Optional[Union[str, Dict[str, str]]]: + return self.get("_nsight", None) + + def rocprof_sys(self) -> Optional[Union[str, Dict[str, Dict[str, str]]]]: + return self.get("_rocprof_sys", None) + + def env_vars(self) -> Dict: + return self.get("env_vars", {}) + + def has_conda(self) -> str: + if self.get("conda"): + return True + return False + + def conda_env_name(self) -> str: + if not self.has_conda() or not isinstance(self["conda"], str): + return None + return self["conda"] + + def conda_config(self) -> str: + if not self.has_conda() or not isinstance(self["conda"], dict): + return None + return json.dumps(self["conda"], sort_keys=True) + + def has_pip(self) -> bool: + if self.get("pip"): + return True + return False + + def has_uv(self) -> bool: + if self.get("uv"): + return True + return False + + def virtualenv_name(self) -> Optional[str]: + if not self.has_pip() or not isinstance(self["pip"], str): + return None + return self["pip"] + + def pip_config(self) -> Dict: + if not self.has_pip() or isinstance(self["pip"], str): + return {} + # Parse and validate field pip on method `__setitem__` + self["pip"] = self["pip"] + return self["pip"] + + def uv_config(self) -> Dict: + if not self.has_uv() or isinstance(self["uv"], str): + return {} + # Parse and validate field pip on method `__setitem__` + self["uv"] = self["uv"] + return self["uv"] + + def get_extension(self, key) -> Optional[str]: + if key not in RuntimeEnv.extensions_fields: + raise ValueError( + f"Extension key must be one of {RuntimeEnv.extensions_fields}, " + f"got: {key}" + ) + return self.get(key) + + def has_py_container(self) -> bool: + if self.get("container"): + return True + return False + + def py_container_image(self) -> Optional[str]: + if not self.has_py_container(): + return None + return self["container"].get("image", "") + + def py_container_worker_path(self) -> Optional[str]: + if not self.has_py_container(): + return None + return self["container"].get("worker_path", "") + + def py_container_run_options(self) -> List: + if not self.has_py_container(): + return None + return self["container"].get("run_options", []) + + def image_uri(self) -> Optional[str]: + return self.get("image_uri") + + def plugins(self) -> List[Tuple[str, Any]]: + result = list() + for key, value in self.items(): + if key not in self.known_fields: + result.append((key, value)) + return result + + +def _validate_no_local_paths(runtime_env: RuntimeEnv): + """Checks that options such as working_dir and py_modules only contain URIs.""" + if not isinstance(runtime_env, RuntimeEnv): + raise TypeError( + f"Expected type to be RuntimeEnv but received {type(runtime_env)} instead." + ) + for option, validate_fn in OPTION_TO_NO_PATH_VALIDATION_FN.items(): + option_val = runtime_env.get(option) + if option_val: + validate_fn(option_val) + + +def _merge_runtime_env( + parent: Optional[RuntimeEnv], + child: Optional[RuntimeEnv], + override: bool = False, +) -> Optional[RuntimeEnv]: + """Merge the parent and child runtime environments. + + If override = True, the child's runtime env overrides the parent's + runtime env in the event of a conflict. + + Merging happens per key (i.e., "conda", "pip", ...), but + "env_vars" are merged per env var key. + + It returns None if Ray fails to merge runtime environments because + of a conflict and `override = False`. + + Args: + parent: Parent runtime env. + child: Child runtime env. + override: If True, the child's runtime env overrides + conflicting fields. + Returns: + The merged runtime env's if Ray successfully merges them. + None if the runtime env's conflict. Empty dict if + parent and child are both None. + """ + if parent is None: + parent = {} + if child is None: + child = {} + + parent = deepcopy(parent) + child = deepcopy(child) + parent_env_vars = parent.pop("env_vars", {}) + child_env_vars = child.pop("env_vars", {}) + + if not override: + if set(parent.keys()).intersection(set(child.keys())): + return None + if set(parent_env_vars.keys()).intersection(set(child_env_vars.keys())): # noqa + return None + + parent.update(child) + parent_env_vars.update(child_env_vars) + if parent_env_vars: + parent["env_vars"] = parent_env_vars + + return parent diff --git a/lib/python3.12/site-packages/ray/scripts/__init__.py b/lib/python3.12/site-packages/ray/scripts/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lib/python3.12/site-packages/ray/scripts/__pycache__/__init__.cpython-312.pyc b/lib/python3.12/site-packages/ray/scripts/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..de961e89cbce93bb88dc6ee751b55f1bbd85bf6e Binary files /dev/null and b/lib/python3.12/site-packages/ray/scripts/__pycache__/__init__.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/scripts/__pycache__/symmetric_run.cpython-312.pyc b/lib/python3.12/site-packages/ray/scripts/__pycache__/symmetric_run.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0740821bca8163d7a8140fffdd5bd58ebd2354fd Binary files /dev/null and b/lib/python3.12/site-packages/ray/scripts/__pycache__/symmetric_run.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/scripts/scripts.py b/lib/python3.12/site-packages/ray/scripts/scripts.py new file mode 100644 index 0000000000000000000000000000000000000000..7c70ee485aa7861a5b871fc74d750154253413c7 --- /dev/null +++ b/lib/python3.12/site-packages/ray/scripts/scripts.py @@ -0,0 +1,2847 @@ +import copy +import json +import logging +import os +import platform +import shutil +import signal +import subprocess +import sys +import time +import urllib +import urllib.parse +import warnings +from datetime import datetime +from typing import List, Optional, Set, Tuple + +import click +import colorama +import requests +import yaml + +import ray +import ray._common.usage.usage_constants as usage_constant +import ray._private.ray_constants as ray_constants +import ray._private.services as services +from ray._common.network_utils import build_address, parse_address +from ray._common.usage import usage_lib +from ray._common.utils import load_class +from ray._private.authentication.authentication_token_setup import ( + ensure_token_if_auth_enabled, +) +from ray._private.internal_api import memory_summary +from ray._private.label_utils import ( + parse_node_labels_from_yaml_file, + parse_node_labels_json, + parse_node_labels_string, +) +from ray._private.log import format_returncode, setup_process_exit_logger +from ray._private.resource_isolation_config import ResourceIsolationConfig +from ray._private.utils import ( + get_ray_client_dependency_error, + parse_resources_json, +) +from ray.autoscaler._private.cli_logger import add_click_logging_options, cf, cli_logger +from ray.autoscaler._private.commands import ( + RUN_ENV_TYPES, + attach_cluster, + create_or_update_cluster, + debug_status, + exec_cluster, + get_cluster_dump_archive, + get_head_node_ip, + get_local_dump_archive, + get_worker_node_ips, + kill_node, + monitor_cluster, + rsync, + teardown_cluster, +) +from ray.autoscaler._private.constants import RAY_PROCESSES +from ray.autoscaler._private.fake_multi_node.node_provider import FAKE_HEAD_NODE_ID +from ray.core.generated import autoscaler_pb2 +from ray.dashboard.modules.metrics import install_and_start_prometheus +from ray.scripts.symmetric_run import symmetric_run +from ray.util.annotations import PublicAPI +from ray.util.check_open_ports import check_open_ports + +import psutil + +logger = logging.getLogger(__name__) + + +def _check_ray_version(gcs_client): + import ray._common.usage.usage_lib as ray_usage_lib + + cluster_metadata = ray_usage_lib.get_cluster_metadata(gcs_client) + if cluster_metadata and cluster_metadata["ray_version"] != ray.__version__: + raise RuntimeError( + "Ray version mismatch: cluster has Ray version " + f'{cluster_metadata["ray_version"]} ' + f"but local Ray version is {ray.__version__}" + ) + + +@click.group() +@click.option( + "--logging-level", + required=False, + default=ray_constants.LOGGER_LEVEL, + type=str, + help=ray_constants.LOGGER_LEVEL_HELP, +) +@click.option( + "--logging-format", + required=False, + default=ray_constants.LOGGER_FORMAT, + type=str, + help=ray_constants.LOGGER_FORMAT_HELP, +) +@click.version_option() +def cli(logging_level, logging_format): + level = logging.getLevelName(logging_level.upper()) + ray._private.ray_logging.setup_logger(level, logging_format) + cli_logger.set_format(format_tmpl=logging_format) + + +@click.command() +@click.argument("cluster_config_file", required=True, type=str) +@click.option( + "--cluster-name", + "-n", + required=False, + type=str, + help="Override the configured cluster name.", +) +@click.option( + "--port", + "-p", + required=False, + type=int, + default=ray_constants.DEFAULT_DASHBOARD_PORT, + help="The local port to forward to the dashboard", +) +@click.option( + "--remote-port", + required=False, + type=int, + default=ray_constants.DEFAULT_DASHBOARD_PORT, + help="The remote port your dashboard runs on", +) +@click.option( + "--no-config-cache", + is_flag=True, + default=False, + help="Disable the local cluster config cache.", +) +@PublicAPI +def dashboard(cluster_config_file, cluster_name, port, remote_port, no_config_cache): + """Port-forward a Ray cluster's dashboard to the local machine.""" + # Sleeping in a loop is preferable to `sleep infinity` because the latter + # only works on linux. + # Find the first open port sequentially from `remote_port`. + try: + port_forward = [ + (port, remote_port), + ] + click.echo( + "Attempting to establish dashboard locally at" + " http://localhost:{}/ connected to" + " remote port {}".format(port, remote_port) + ) + # We want to probe with a no-op that returns quickly to avoid + # exceptions caused by network errors. + exec_cluster( + cluster_config_file, + override_cluster_name=cluster_name, + port_forward=port_forward, + no_config_cache=no_config_cache, + ) + click.echo("Successfully established connection.") + except Exception as e: + raise click.ClickException( + "Failed to forward dashboard from remote port {1} to local port " + "{0}. There are a couple possibilities: \n 1. The remote port is " + "incorrectly specified \n 2. The local port {0} is already in " + "use.\n The exception is: {2}".format(port, remote_port, e) + ) from None + + +def continue_debug_session(live_jobs: Set[str]): + """Continue active debugging session. + + This function will connect 'ray debug' to the right debugger + when a user is stepping between Ray tasks. + """ + active_sessions = ray.experimental.internal_kv._internal_kv_list( + "RAY_PDB_", namespace=ray_constants.KV_NAMESPACE_PDB + ) + + for active_session in active_sessions: + if active_session.startswith(b"RAY_PDB_CONTINUE"): + # Check to see that the relevant job is still alive. + data = ray.experimental.internal_kv._internal_kv_get( + active_session, namespace=ray_constants.KV_NAMESPACE_PDB + ) + if json.loads(data)["job_id"] not in live_jobs: + ray.experimental.internal_kv._internal_kv_del( + active_session, namespace=ray_constants.KV_NAMESPACE_PDB + ) + continue + + print("Continuing pdb session in different process...") + key = b"RAY_PDB_" + active_session[len("RAY_PDB_CONTINUE_") :] + while True: + data = ray.experimental.internal_kv._internal_kv_get( + key, namespace=ray_constants.KV_NAMESPACE_PDB + ) + if data: + session = json.loads(data) + if "exit_debugger" in session or session["job_id"] not in live_jobs: + ray.experimental.internal_kv._internal_kv_del( + key, namespace=ray_constants.KV_NAMESPACE_PDB + ) + return + host, port = parse_address(session["pdb_address"]) + ray.util.rpdb._connect_pdb_client(host, int(port)) + ray.experimental.internal_kv._internal_kv_del( + key, namespace=ray_constants.KV_NAMESPACE_PDB + ) + continue_debug_session(live_jobs) + return + time.sleep(1.0) + + +def none_to_empty(s): + if s is None: + return "" + return s + + +def format_table(table): + """Format a table as a list of lines with aligned columns.""" + result = [] + col_width = [max(len(x) for x in col) for col in zip(*table)] + for line in table: + result.append( + " | ".join("{0:{1}}".format(x, col_width[i]) for i, x in enumerate(line)) + ) + return result + + +@cli.command() +@click.option( + "--address", required=False, type=str, help="Override the address to connect to." +) +@click.option( + "-v", + "--verbose", + required=False, + is_flag=True, + help="Shows additional fields in breakpoint selection page.", +) +def debug(address: str, verbose: bool): + """Show all active breakpoints and exceptions in the Ray debugger.""" + address = services.canonicalize_bootstrap_address_or_die(address) + logger.info(f"Connecting to Ray instance at {address}.") + ray.init(address=address, log_to_driver=False) + if os.environ.get("RAY_DEBUG", "1") != "legacy": + print( + f"{colorama.Fore.YELLOW}NOTE: The distributed debugger " + "https://docs.ray.io/en/latest/ray-observability" + "/ray-distributed-debugger.html is now the default " + "due to better interactive debugging support. If you want " + "to keep using 'ray debug' please set RAY_DEBUG=legacy " + f"in your cluster (e.g. via runtime environment).{colorama.Fore.RESET}" + ) + while True: + # Used to filter out and clean up entries from dead jobs. + live_jobs = { + job["JobID"] for job in ray._private.state.jobs() if not job["IsDead"] + } + continue_debug_session(live_jobs) + + active_sessions = ray.experimental.internal_kv._internal_kv_list( + "RAY_PDB_", namespace=ray_constants.KV_NAMESPACE_PDB + ) + print("Active breakpoints:") + sessions_data = [] + for active_session in active_sessions: + data = json.loads( + ray.experimental.internal_kv._internal_kv_get( + active_session, namespace=ray_constants.KV_NAMESPACE_PDB + ) + ) + # Check that the relevant job is alive, else clean up the entry. + if data["job_id"] in live_jobs: + sessions_data.append(data) + else: + ray.experimental.internal_kv._internal_kv_del( + active_session, namespace=ray_constants.KV_NAMESPACE_PDB + ) + sessions_data = sorted( + sessions_data, key=lambda data: data["timestamp"], reverse=True + ) + if verbose: + table = [ + [ + "index", + "timestamp", + "Ray task", + "filename:lineno", + "Task ID", + "Worker ID", + "Actor ID", + "Node ID", + ] + ] + for i, data in enumerate(sessions_data): + date = datetime.utcfromtimestamp(data["timestamp"]).strftime( + "%Y-%m-%d %H:%M:%S" + ) + table.append( + [ + str(i), + date, + data["proctitle"], + data["filename"] + ":" + str(data["lineno"]), + data["task_id"], + data["worker_id"], + none_to_empty(data["actor_id"]), + data["node_id"], + ] + ) + else: + # Non verbose mode: no IDs. + table = [["index", "timestamp", "Ray task", "filename:lineno"]] + for i, data in enumerate(sessions_data): + date = datetime.utcfromtimestamp(data["timestamp"]).strftime( + "%Y-%m-%d %H:%M:%S" + ) + table.append( + [ + str(i), + date, + data["proctitle"], + data["filename"] + ":" + str(data["lineno"]), + ] + ) + for i, line in enumerate(format_table(table)): + print(line) + if i >= 1 and not sessions_data[i - 1]["traceback"].startswith( + "NoneType: None" + ): + print(sessions_data[i - 1]["traceback"]) + inp = input("Enter breakpoint index or press enter to refresh: ") + if inp == "": + print() + continue + else: + index = int(inp) + session = json.loads( + ray.experimental.internal_kv._internal_kv_get( + active_sessions[index], namespace=ray_constants.KV_NAMESPACE_PDB + ) + ) + host, port = parse_address(session["pdb_address"]) + ray.util.rpdb._connect_pdb_client(host, int(port)) + + +@cli.command() +@click.option( + "--node-ip-address", required=False, type=str, help="the IP address of this node" +) +@click.option("--address", required=False, type=str, help="the address to use for Ray") +@click.option( + "--port", + type=int, + required=False, + help=f"the port of the head ray process. If not provided, defaults to " + f"{ray_constants.DEFAULT_PORT}; if port is set to 0, we will" + f" allocate an available port.", +) +@click.option( + "--node-name", + required=False, + hidden=True, + type=str, + help="the user-provided identifier or name for this node. " + "Defaults to the node's ip_address", +) +@click.option( + "--redis-username", + required=False, + hidden=True, + type=str, + default=ray_constants.REDIS_DEFAULT_USERNAME, + help="If provided, secure Redis ports with this username", +) +@click.option( + "--redis-password", + required=False, + hidden=True, + type=str, + default=ray_constants.REDIS_DEFAULT_PASSWORD, + help="If provided, secure Redis ports with this password", +) +@click.option( + "--redis-shard-ports", + required=False, + hidden=True, + type=str, + help="the port to use for the Redis shards other than the primary Redis shard", +) +@click.option( + "--object-manager-port", + required=False, + type=int, + help="the port to use for starting the object manager", +) +@click.option( + "--node-manager-port", + required=False, + type=int, + default=0, + help="the port to use for starting the node manager", +) +@click.option( + "--min-worker-port", + required=False, + type=int, + default=10002, + help="the lowest port number that workers will bind on. If not set, " + "random ports will be chosen.", +) +@click.option( + "--max-worker-port", + required=False, + type=int, + default=19999, + help="the highest port number that workers will bind on. If set, " + "'--min-worker-port' must also be set.", +) +@click.option( + "--worker-port-list", + required=False, + help="a comma-separated list of open ports for workers to bind on. " + "Overrides '--min-worker-port' and '--max-worker-port'.", +) +@click.option( + "--ray-client-server-port", + required=False, + type=int, + default=None, + help="the port number the ray client server binds on, default to 10001, " + "or None if ray[client] is not installed.", +) +@click.option( + "--memory", + required=False, + hidden=True, + type=int, + help="The amount of memory (in bytes) to make available to workers. " + "By default, this is set to the available memory on the node.", +) +@click.option( + "--object-store-memory", + required=False, + type=int, + help="The amount of memory (in bytes) to start the object store with. " + "By default, this is 30% of available system memory capped by " + "the shm size and 200G but can be set higher.", +) +@click.option( + "--num-cpus", required=False, type=int, help="the number of CPUs on this node" +) +@click.option( + "--num-gpus", required=False, type=int, help="the number of GPUs on this node" +) +@click.option( + "--resources", + required=False, + default="{}", + type=str, + help="A JSON serialized dictionary mapping resource name to resource quantity." + + ( + r""" + +Windows command prompt users must ensure to double quote command line arguments. Because +JSON requires the use of double quotes you must escape these arguments as well, for +example: + + ray start --head --resources="{\"special_hardware\":1, \"custom_label\":1}" + +Windows powershell users need additional escaping: + + ray start --head --resources="{\""special_hardware\"":1, \""custom_label\"":1}" +""" + if platform.system() == "Windows" + else "" + ), +) +@click.option( + "--head", + is_flag=True, + default=False, + help="provide this argument for the head node", +) +@click.option( + "--include-dashboard", + default=None, + type=bool, + help="provide this argument to start the Ray dashboard GUI", +) +@click.option( + "--dashboard-host", + required=False, + default=ray_constants.DEFAULT_DASHBOARD_IP, + help="the host to bind the dashboard server to, either localhost " + "(127.0.0.1) or 0.0.0.0 (available from all interfaces). By default, this " + "is 127.0.0.1", +) +@click.option( + "--dashboard-port", + required=False, + type=int, + default=ray_constants.DEFAULT_DASHBOARD_PORT, + help="the port to bind the dashboard server to--defaults to {}".format( + ray_constants.DEFAULT_DASHBOARD_PORT + ), +) +@click.option( + "--dashboard-agent-listen-port", + type=int, + default=ray_constants.DEFAULT_DASHBOARD_AGENT_LISTEN_PORT, + help="the port for dashboard agents to listen for http on.", +) +@click.option( + "--dashboard-agent-grpc-port", + type=int, + default=None, + help="the port for dashboard agents to listen for grpc on.", +) +@click.option( + "--runtime-env-agent-port", + type=int, + default=None, + help="The port for the runtime environment agents to listen for http on.", +) +@click.option( + "--block", + is_flag=True, + default=False, + help="provide this argument to block forever in this command." + "Process exit logs will be saved to ray_process_exit.log in the logs directory.", +) +@click.option( + "--plasma-directory", + required=False, + type=str, + help="object store directory for memory mapped files", +) +@click.option( + "--object-spilling-directory", + required=False, + type=str, + help="The path to spill objects to. This path will also be used as the fallback directory when the object store is full of in-use objects and cannot spill.", +) +@click.option( + "--autoscaling-config", + required=False, + type=str, + help="the file that contains the autoscaling config", +) +@click.option( + "--no-redirect-output", + is_flag=True, + default=False, + help="do not redirect non-worker stdout and stderr to files", +) +@click.option( + "--temp-dir", + default=None, + help="manually specify the root temporary dir of the Ray process, only " + "works when --head is specified", +) +@click.option( + "--system-config", + default=None, + hidden=True, + type=json.loads, + help="Override system configuration defaults.", +) +@click.option( + "--enable-object-reconstruction", + is_flag=True, + default=False, + hidden=True, + help="Specify whether object reconstruction will be used for this cluster.", +) +@click.option( + "--metrics-export-port", + type=int, + default=None, + help="the port to use to expose Ray metrics through a Prometheus endpoint.", +) +@click.option( + "--no-monitor", + is_flag=True, + hidden=True, + default=False, + help="If True, the ray autoscaler monitor for this cluster will not be started.", +) +@click.option( + "--tracing-startup-hook", + type=str, + hidden=True, + default=None, + help="The function that sets up tracing with a tracing provider, remote " + "span processor, and additional instruments. See docs.ray.io/tracing.html " + "for more info.", +) +@click.option( + "--ray-debugger-external", + is_flag=True, + default=False, + help="Make the Ray debugger available externally to the node. This is only " + "safe to activate if the node is behind a firewall.", +) +@click.option( + "--disable-usage-stats", + is_flag=True, + default=False, + help="If True, the usage stats collection will be disabled.", +) +@click.option( + "--labels", + required=False, + hidden=True, + default="", + type=str, + help="a string list of key-value pairs mapping label name to label value." + "These values take precedence over conflicting keys passed in from --labels-file." + 'Ex: --labels "key1=val1,key2=val2"', +) +@click.option( + "--labels-file", + required=False, + hidden=True, + default="", + type=str, + help="a path to a YAML file containing a dictionary mapping of label keys to values.", +) +@click.option( + "--include-log-monitor", + default=None, + type=bool, + help="If set to True or left unset, a log monitor will start monitoring " + "the log files of all processes on this node and push their contents to GCS. " + "Only one log monitor should be started per physical host to avoid log " + "duplication on the driver process.", +) +@click.option( + "--enable-resource-isolation", + required=False, + is_flag=True, + default=False, + help="Enable resource isolation through cgroupv2 by reserving memory and cpu " + "resources for ray system processes. To use, only cgroupv2 (not cgroupv1) must " + "be enabled with read and write permissions for the raylet. Cgroup memory and " + "cpu controllers must also be enabled.", +) +@click.option( + "--system-reserved-cpu", + required=False, + type=float, + help=" The number of cpu cores to reserve for ray system processes. " + "Cores can be fractional i.e. 1.5 means one and a half a cpu core. " + "By default, the value will be atleast 1 core, and at maximum 3 cores. The default value " + "is calculated using the formula min(3.0, max(1.0, 0.05 * num_cores_on_the_system)) " + "This option only works if --enable_resource_isolation is set.", +) +@click.option( + "--system-reserved-memory", + required=False, + type=int, + help="The amount of memory (in bytes) to reserve for ray system processes. " + "By default, the value will be atleast 500MB, and at most 10GB. The default value is " + "calculated using the formula min(10GB, max(500MB, 0.10 * memory_available_on_the_system)) " + "This option only works if --enable_resource_isolation is set.", +) +@click.option( + "--cgroup-path", + required=False, + hidden=True, + type=str, + help="The path for the cgroup the raylet should use to enforce resource isolation. " + "By default, the cgroup used for resource isolation will be /sys/fs/cgroup. " + "The process starting ray must have read/write permissions to this path. " + "Cgroup memory and cpu controllers be enabled for this cgroup. " + "This option only works if enable_resource_isolation is True.", +) +@add_click_logging_options +@PublicAPI +def start( + node_ip_address, + address, + port, + node_name, + redis_username, + redis_password, + redis_shard_ports, + object_manager_port, + node_manager_port, + min_worker_port, + max_worker_port, + worker_port_list, + ray_client_server_port, + memory, + object_store_memory, + num_cpus, + num_gpus, + resources, + head, + include_dashboard, + dashboard_host, + dashboard_port, + dashboard_agent_listen_port, + dashboard_agent_grpc_port, + runtime_env_agent_port, + block, + plasma_directory, + object_spilling_directory, + autoscaling_config, + no_redirect_output, + temp_dir, + system_config, + enable_object_reconstruction, + metrics_export_port, + no_monitor, + tracing_startup_hook, + ray_debugger_external, + disable_usage_stats, + labels, + labels_file, + include_log_monitor, + enable_resource_isolation, + system_reserved_cpu, + system_reserved_memory, + cgroup_path, +): + """Start Ray processes manually on the local machine.""" + + # Whether the original arguments include node_ip_address. + include_node_ip_address = False + if node_ip_address is not None: + include_node_ip_address = True + node_ip_address = services.resolve_ip_for_localhost(node_ip_address) + + resources = parse_resources_json(resources, cli_logger, cf) + + # Compose labels passed in with `--labels` and `--labels-file`. + # In the case of duplicate keys, the values from `--labels` take precedence. + try: + labels_from_file = parse_node_labels_from_yaml_file(labels_file) + except Exception as e: + cli_logger.abort( + "The file at `{}` is not a valid YAML file, detailed error: {} " + "Valid values look like this: `{}`", + cf.bold(f"--labels-file={labels_file}"), + str(e), + cf.bold("--labels-file='gpu_type: A100\nregion: us'"), + ) + try: + # Attempt to parse labels from new string format first. + labels_from_string = parse_node_labels_string(labels) + except Exception as e: + try: + # Fall back to JSON format if parsing from string fails. + labels_from_string = parse_node_labels_json(labels) + warnings.warn( + "passing node labels with `--labels` in JSON format is " + "deprecated and will be removed in a future version of Ray.", + DeprecationWarning, + stacklevel=2, + ) + except Exception: + # If parsing labels from both formats fails, return the original error message. + cli_logger.abort( + "`{}` is not a valid string of key-value pairs, detailed error: {} " + "Valid values look like this: `{}`", + cf.bold(f"--labels={labels}"), + str(e), + cf.bold('--labels="key1=val1,key2=val2"'), + ) + labels_dict = {**labels_from_file, **labels_from_string} + if temp_dir and not head: + cli_logger.warning( + f"`--temp-dir={temp_dir}` option will be ignored. " + "`--head` is a required flag to use `--temp-dir`. " + "temp_dir is only configurable from a head node. " + "All the worker nodes will use the same temp_dir as a head node. " + ) + temp_dir = None + + resource_isolation_config = ResourceIsolationConfig( + enable_resource_isolation=enable_resource_isolation, + cgroup_path=cgroup_path, + system_reserved_cpu=system_reserved_cpu, + system_reserved_memory=system_reserved_memory, + ) + + redirect_output = None if not no_redirect_output else True + + # no client, no port -> ok + # no port, has client -> default to 10001 + # has port, no client -> value error + # has port, has client -> ok, check port validity + has_ray_client = get_ray_client_dependency_error() is None + if has_ray_client and ray_client_server_port is None: + ray_client_server_port = 10001 + + ray_params = ray._private.parameter.RayParams( + node_ip_address=node_ip_address, + node_name=node_name if node_name else node_ip_address, + min_worker_port=min_worker_port, + max_worker_port=max_worker_port, + worker_port_list=worker_port_list, + ray_client_server_port=ray_client_server_port, + object_manager_port=object_manager_port, + node_manager_port=node_manager_port, + memory=memory, + object_store_memory=object_store_memory, + redis_username=redis_username, + redis_password=redis_password, + redirect_output=redirect_output, + num_cpus=num_cpus, + num_gpus=num_gpus, + resources=resources, + labels=labels_dict, + autoscaling_config=autoscaling_config, + plasma_directory=plasma_directory, + object_spilling_directory=object_spilling_directory, + huge_pages=False, + temp_dir=temp_dir, + include_dashboard=include_dashboard, + dashboard_host=dashboard_host, + dashboard_port=dashboard_port, + dashboard_agent_listen_port=dashboard_agent_listen_port, + metrics_agent_port=dashboard_agent_grpc_port, + runtime_env_agent_port=runtime_env_agent_port, + _system_config=system_config, + enable_object_reconstruction=enable_object_reconstruction, + metrics_export_port=metrics_export_port, + no_monitor=no_monitor, + tracing_startup_hook=tracing_startup_hook, + ray_debugger_external=ray_debugger_external, + include_log_monitor=include_log_monitor, + resource_isolation_config=resource_isolation_config, + ) + + if ray_constants.RAY_START_HOOK in os.environ: + load_class(os.environ[ray_constants.RAY_START_HOOK])(ray_params, head) + + if head: + # Start head node. + + if disable_usage_stats: + usage_lib.set_usage_stats_enabled_via_env_var(False) + usage_lib.show_usage_stats_prompt(cli=True) + cli_logger.newline() + + if port is None: + port = ray_constants.DEFAULT_PORT + + # Set bootstrap port. + assert ray_params.redis_port is None + assert ray_params.gcs_server_port is None + ray_params.gcs_server_port = port + + if os.environ.get("RAY_FAKE_CLUSTER"): + ray_params.env_vars = { + "RAY_OVERRIDE_NODE_ID_FOR_TESTING": FAKE_HEAD_NODE_ID + } + + if ( + usage_constant.KUBERAY_ENV in os.environ # KubeRay exclusive. + and "RAY_CLOUD_INSTANCE_ID" in os.environ # required by autoscaler v2. + and "RAY_NODE_TYPE_NAME" in os.environ # required by autoscaler v2. + ): + # If this Ray cluster is managed by KubeRay and RAY_CLOUD_INSTANCE_ID and RAY_NODE_TYPE_NAME are set, + # we enable the v2 autoscaler by default if RAY_enable_autoscaler_v2 is not set. + os.environ.setdefault("RAY_enable_autoscaler_v2", "1") + + num_redis_shards = None + # Start Ray on the head node. + if redis_shard_ports is not None and address is None: + redis_shard_ports = redis_shard_ports.split(",") + # Infer the number of Redis shards from the ports if the number is + # not provided. + num_redis_shards = len(redis_shard_ports) + + # This logic is deprecated and will be removed later. + if address is not None: + cli_logger.warning( + "Specifying {} for external Redis address is deprecated. " + "Please specify environment variable {}={} instead.", + cf.bold("--address"), + cf.bold("RAY_REDIS_ADDRESS"), + address, + ) + external_addresses = address.split(",") + + # We reuse primary redis as sharding when there's only one + # instance provided. + if len(external_addresses) == 1: + external_addresses.append(external_addresses[0]) + + ray_params.update_if_absent(external_addresses=external_addresses) + num_redis_shards = len(external_addresses) - 1 + if redis_username == ray_constants.REDIS_DEFAULT_USERNAME: + cli_logger.warning( + "`{}` should not be specified as empty string if " + "external Redis server(s) `{}` points to requires " + "username.", + cf.bold("--redis-username"), + cf.bold("--address"), + ) + if redis_password == ray_constants.REDIS_DEFAULT_PASSWORD: + cli_logger.warning( + "`{}` should not be specified as empty string if " + "external redis server(s) `{}` points to requires " + "password.", + cf.bold("--redis-password"), + cf.bold("--address"), + ) + + # Get the node IP address if one is not provided. + ray_params.update_if_absent(node_ip_address=services.get_node_ip_address()) + cli_logger.labeled_value("Local node IP", ray_params.node_ip_address) + + # Initialize Redis settings. + ray_params.update_if_absent( + redis_shard_ports=redis_shard_ports, + num_redis_shards=num_redis_shards, + redis_max_clients=None, + ) + + # Fail early when starting a new cluster when one is already running + if address is None: + default_address = build_address(ray_params.node_ip_address, port) + bootstrap_address = services.find_bootstrap_address(temp_dir) + if ( + default_address == bootstrap_address + and bootstrap_address in services.find_gcs_addresses() + ): + # The default address is already in use by a local running GCS + # instance. + raise ConnectionError( + f"Ray is trying to start at {default_address}, " + f"but is already running at {bootstrap_address}. " + "Please specify a different port using the `--port`" + " flag of `ray start` command." + ) + + # Ensure auth token is available if authentication mode is token + try: + ensure_token_if_auth_enabled(system_config, create_token_if_missing=False) + except ray.exceptions.AuthenticationError: + raise RuntimeError( + "Failed to load authentication token. To generate a token for local development, use `ray get-auth-token --generate`. For remote clusters, ensure that the token is propagated to all nodes of the cluster when token authentication is enabled." + ) + + node = ray._private.node.Node( + ray_params, head=True, shutdown_at_exit=block, spawn_reaper=block + ) + + bootstrap_address = node.address + + # this is a noop if new-style is not set, so the old logger calls + # are still in place + cli_logger.newline() + startup_msg = "Ray runtime started." + cli_logger.success("-" * len(startup_msg)) + cli_logger.success(startup_msg) + cli_logger.success("-" * len(startup_msg)) + cli_logger.newline() + with cli_logger.group("Next steps"): + dashboard_url = node.address_info["webui_url"] + if ray_constants.ENABLE_RAY_CLUSTER: + cli_logger.print("To add another node to this Ray cluster, run") + # NOTE(kfstorm): Java driver rely on this line to get the address + # of the cluster. Please be careful when updating this line. + cli_logger.print( + cf.bold(" {} ray start --address='{}'"), + f" {ray_constants.ENABLE_RAY_CLUSTERS_ENV_VAR}=1" + if ray_constants.IS_WINDOWS_OR_OSX + else "", + bootstrap_address, + ) + + cli_logger.newline() + cli_logger.print("To connect to this Ray cluster:") + with cli_logger.indented(): + cli_logger.print("{} ray", cf.magenta("import")) + cli_logger.print( + "ray{}init({})", + cf.magenta("."), + "_node_ip_address{}{}".format( + cf.magenta("="), cf.yellow("'" + node_ip_address + "'") + ) + if include_node_ip_address + else "", + ) + + if dashboard_url: + cli_logger.newline() + cli_logger.print("To submit a Ray job using the Ray Jobs CLI:") + cli_logger.print( + cf.bold( + " RAY_API_SERVER_ADDRESS='http://{}' ray job submit " + "--working-dir . " + "-- python my_script.py" + ), + dashboard_url, + ) + cli_logger.newline() + cli_logger.print( + "See https://docs.ray.io/en/latest/cluster/running-applications" + "/job-submission/index.html " + ) + cli_logger.print( + "for more information on submitting Ray jobs to the Ray cluster." + ) + + cli_logger.newline() + cli_logger.print("To terminate the Ray runtime, run") + cli_logger.print(cf.bold(" ray stop")) + + cli_logger.newline() + cli_logger.print("To view the status of the cluster, use") + cli_logger.print(" {}".format(cf.bold("ray status"))) + + if dashboard_url: + cli_logger.newline() + cli_logger.print("To monitor and debug Ray, view the dashboard at ") + cli_logger.print( + " {}".format( + cf.bold(dashboard_url), + ) + ) + + cli_logger.newline() + cli_logger.print( + cf.underlined( + "If connection to the dashboard fails, check your " + "firewall settings and " + "network configuration." + ) + ) + ray_params.gcs_address = bootstrap_address + else: + # Start worker node. + if not ray_constants.ENABLE_RAY_CLUSTER: + cli_logger.abort( + "Multi-node Ray clusters are not supported on Windows and OSX. " + "Restart the Ray cluster with the environment variable `{}=1` " + "to proceed anyway.", + cf.bold(ray_constants.ENABLE_RAY_CLUSTERS_ENV_VAR), + ) + raise Exception( + "Multi-node Ray clusters are not supported on Windows and OSX. " + "Restart the Ray cluster with the environment variable " + f"`{ray_constants.ENABLE_RAY_CLUSTERS_ENV_VAR}=1` to proceed " + "anyway.", + ) + + # Ensure `--address` flag is specified. + if address is None: + cli_logger.abort( + "`{}` is a required flag unless starting a head node with `{}`.", + cf.bold("--address"), + cf.bold("--head"), + ) + raise Exception( + "`--address` is a required flag unless starting a " + "head node with `--head`." + ) + + # Raise error if any head-only flag are specified. + head_only_flags = { + "--port": port, + "--redis-shard-ports": redis_shard_ports, + "--include-dashboard": include_dashboard, + } + for flag, val in head_only_flags.items(): + if val is None: + continue + cli_logger.abort( + "`{}` should only be specified when starting head node with `{}`.", + cf.bold(flag), + cf.bold("--head"), + ) + raise ValueError( + f"{flag} should only be specified when starting head node " + "with `--head`." + ) + + # Start Ray on a non-head node. + bootstrap_address = services.canonicalize_bootstrap_address( + address, temp_dir=temp_dir + ) + + if bootstrap_address is None: + cli_logger.abort( + "Cannot canonicalize address `{}={}`.", + cf.bold("--address"), + cf.bold(address), + ) + raise Exception("Cannot canonicalize address " f"`--address={address}`.") + + ray_params.gcs_address = bootstrap_address + + # Get the node IP address if one is not provided. + ray_params.update_if_absent( + node_ip_address=services.get_node_ip_address(bootstrap_address) + ) + + cli_logger.labeled_value("Local node IP", ray_params.node_ip_address) + + # Ensure auth token is available if authentication mode is token + ensure_token_if_auth_enabled(system_config, create_token_if_missing=False) + + node = ray._private.node.Node( + ray_params, head=False, shutdown_at_exit=block, spawn_reaper=block + ) + temp_dir = node.get_temp_dir_path() + + # Ray and Python versions should probably be checked before + # initializing Node. + node.check_version_info() + + cli_logger.newline() + startup_msg = "Ray runtime started." + cli_logger.success("-" * len(startup_msg)) + cli_logger.success(startup_msg) + cli_logger.success("-" * len(startup_msg)) + cli_logger.newline() + cli_logger.print("To terminate the Ray runtime, run") + cli_logger.print(cf.bold(" ray stop")) + cli_logger.flush() + + assert ray_params.gcs_address is not None + ray._private.utils.write_ray_address(ray_params.gcs_address, temp_dir) + + if block: + logs_dir = node.get_logs_dir_path() + process_exit_log_path = os.path.join(logs_dir, "ray_process_exit.log") + cli_logger.newline() + with cli_logger.group(cf.bold("--block")): + cli_logger.print( + "This command will now block forever until terminated by a signal." + ) + cli_logger.print( + "Running subprocesses are monitored and a message will be " + "printed if any of them terminate unexpectedly. Subprocesses " + "exit with SIGTERM will be treated as graceful, thus NOT reported." + ) + cli_logger.print( + "Process exit logs will be saved to: {}", cf.bold(process_exit_log_path) + ) + cli_logger.flush() + try: + process_exit_logger = setup_process_exit_logger(process_exit_log_path) + except Exception as e: + cli_logger.warning("Failed to init process exit logger: {}", e) + process_exit_logger = None + + while True: + time.sleep(1) + deceased = node.dead_processes() + + # Report unexpected exits of subprocesses with unexpected return codes. + # We are explicitly expecting SIGTERM because this is how `ray stop` sends + # shutdown signal to subprocesses, i.e. log_monitor, raylet... + # NOTE(rickyyx): We are treating 128+15 as an expected return code since + # this is what autoscaler/_private/monitor.py does upon SIGTERM + # handling. + expected_return_codes = [ + 0, + signal.SIGTERM, + -1 * signal.SIGTERM, + 128 + signal.SIGTERM, + ] + unexpected_deceased = [ + (process_type, process) + for process_type, process in deceased + if process.returncode not in expected_return_codes + ] + if len(unexpected_deceased) > 0: + cli_logger.newline() + cli_logger.error("Some Ray subprocesses exited unexpectedly:") + + lines_for_file = [] + with cli_logger.indented(): + for process_type, process in unexpected_deceased: + cli_logger.error( + "{}", + cf.bold(str(process_type)), + _tags={"exit code": str(process.returncode)}, + ) + rc = getattr(process, "returncode", None) + rc_str = format_returncode(rc) + lines_for_file.append(f" {process_type} [exit code={rc_str}]") + try: + file_msg = ( + "Some Ray subprocesses exited unexpectedly:\n" + + "\n".join(lines_for_file) + ) + process_exit_logger.error("%s", file_msg) + except Exception as e: + cli_logger.warning("Failed to write process exit log: {}", e) + + cli_logger.newline() + cli_logger.error("Remaining processes will be killed.") + + # explicitly kill all processes since atexit handlers + # will not exit with errors. + node.kill_all_processes(check_alive=False, allow_graceful=False) + os._exit(1) + # not-reachable + + +@cli.command() +@click.option( + "-f", + "--force", + is_flag=True, + help="If set, ray will send SIGKILL instead of SIGTERM.", +) +@click.option( + "-g", + "--grace-period", + default=16, + help=( + "The time in seconds ray waits for processes to be properly terminated. " + "If processes are not terminated within the grace period, " + "they are forcefully terminated after the grace period. " + ), +) +@add_click_logging_options +@PublicAPI +def stop(force: bool, grace_period: int): + """Stop Ray processes manually on the local machine.""" + is_linux = sys.platform.startswith("linux") + total_procs_found = 0 + total_procs_stopped = 0 + procs_not_gracefully_killed = [] + + def kill_procs( + force: bool, grace_period: int, processes_to_kill: List[str] + ) -> Tuple[int, int, List[psutil.Process]]: + """Find all processes from `processes_to_kill` and terminate them. + + Unless `force` is specified, it gracefully kills processes. If + processes are not cleaned within `grace_period`, it force kill all + remaining processes. + + Returns: + total_procs_found: Total number of processes found from + `processes_to_kill` is added. + total_procs_stopped: Total number of processes gracefully + stopped from `processes_to_kill` is added. + procs_not_gracefully_killed: If processes are not killed + gracefully, they are added here. + """ + process_infos = [] + for proc in psutil.process_iter(["name", "cmdline"]): + try: + process_infos.append((proc, proc.name(), proc.cmdline())) + except psutil.Error: + pass + + stopped = [] + for keyword, filter_by_cmd in processes_to_kill: + if filter_by_cmd and is_linux and len(keyword) > 15: + # getting here is an internal bug, so we do not use cli_logger + msg = ( + "The filter string should not be more than {} " + "characters. Actual length: {}. Filter: {}" + ).format(15, len(keyword), keyword) + raise ValueError(msg) + + found = [] + for candidate in process_infos: + proc, proc_cmd, proc_args = candidate + corpus = ( + proc_cmd if filter_by_cmd else subprocess.list2cmdline(proc_args) + ) + if keyword in corpus: + found.append(candidate) + for proc, proc_cmd, proc_args in found: + proc_string = str(subprocess.list2cmdline(proc_args)) + try: + if force: + proc.kill() + else: + # TODO(mehrdadn): On Windows, this is forceful termination. + # We don't want CTRL_BREAK_EVENT, because that would + # terminate the entire process group. What to do? + proc.terminate() + + if force: + cli_logger.verbose( + "Killed `{}` {} ", + cf.bold(proc_string), + cf.dimmed("(via SIGKILL)"), + ) + else: + cli_logger.verbose( + "Send termination request to `{}` {}", + cf.bold(proc_string), + cf.dimmed("(via SIGTERM)"), + ) + + stopped.append(proc) + except psutil.NoSuchProcess: + cli_logger.verbose( + "Attempted to stop `{}`, but process was already dead.", + cf.bold(proc_string), + ) + except (psutil.Error, OSError) as ex: + cli_logger.error( + "Could not terminate `{}` due to {}", + cf.bold(proc_string), + str(ex), + ) + + # Wait for the processes to actually stop. + # Dedup processes. + stopped, alive = psutil.wait_procs(stopped, timeout=0) + procs_to_kill = stopped + alive + total_found = len(procs_to_kill) + + # Wait for grace period to terminate processes. + gone_procs = set() + + def on_terminate(proc): + gone_procs.add(proc) + cli_logger.print(f"{len(gone_procs)}/{total_found} stopped.", end="\r") + + stopped, alive = psutil.wait_procs( + procs_to_kill, timeout=grace_period, callback=on_terminate + ) + total_stopped = len(stopped) + + # For processes that are not killed within the grace period, + # we send force termination signals. + for proc in alive: + proc.kill() + # Wait a little bit to make sure processes are killed forcefully. + psutil.wait_procs(alive, timeout=2) + return total_found, total_stopped, alive + + # Process killing procedure: we put processes into 3 buckets. + # Bucket 1: raylet + # Bucket 2: all other processes, e.g. dashboard, runtime env agents + # Bucket 3: gcs_server. + # + # For each bucket, we send sigterm to all processes, then wait for 30s, then if + # they are still alive, send sigkill. + processes_to_kill = RAY_PROCESSES + # Raylet should exit before all other processes exit. + # Otherwise, fate-sharing agents will complain and exit. + assert processes_to_kill[0][0] == "raylet" + + # GCS should exit after all other processes exit. + # Otherwise, some of processes may exit with an unexpected + # exit code which breaks ray start --block. + assert processes_to_kill[-1][0] == "gcs_server" + + buckets = [[processes_to_kill[0]], processes_to_kill[1:-1], [processes_to_kill[-1]]] + + for bucket in buckets: + found, stopped, alive = kill_procs(force, grace_period / len(buckets), bucket) + total_procs_found += found + total_procs_stopped += stopped + procs_not_gracefully_killed.extend(alive) + + # Print the termination result. + if total_procs_found == 0: + cli_logger.print("Did not find any active Ray processes.") + else: + if total_procs_stopped == total_procs_found: + cli_logger.success("Stopped all {} Ray processes.", total_procs_stopped) + else: + cli_logger.warning( + f"Stopped only {total_procs_stopped} out of {total_procs_found} " + f"Ray processes within the grace period {grace_period} seconds. " + f"Set `{cf.bold('-v')}` to see more details. " + f"Remaining processes {procs_not_gracefully_killed} " + "will be forcefully terminated.", + ) + cli_logger.warning( + f"You can also use `{cf.bold('--force')}` to forcefully terminate " + "processes or set higher `--grace-period` to wait longer time for " + "proper termination." + ) + + # NOTE(swang): This will not reset the cluster address for a user-defined + # temp_dir. This is fine since it will get overwritten the next time we + # call `ray start`. + ray._common.utils.reset_ray_address() + + +@cli.command() +@click.argument("cluster_config_file", required=True, type=str) +@click.option( + "--min-workers", + required=False, + type=int, + help="Override the configured min worker node count for the cluster.", +) +@click.option( + "--max-workers", + required=False, + type=int, + help="Override the configured max worker node count for the cluster.", +) +@click.option( + "--no-restart", + is_flag=True, + default=False, + help=( + "Whether to skip restarting Ray services during the update. " + "This avoids interrupting running jobs." + ), +) +@click.option( + "--restart-only", + is_flag=True, + default=False, + help=( + "Whether to skip running setup commands and only restart Ray. " + "This cannot be used with 'no-restart'." + ), +) +@click.option( + "--yes", "-y", is_flag=True, default=False, help="Don't ask for confirmation." +) +@click.option( + "--cluster-name", + "-n", + required=False, + type=str, + help="Override the configured cluster name.", +) +@click.option( + "--no-config-cache", + is_flag=True, + default=False, + help="Disable the local cluster config cache.", +) +@click.option( + "--redirect-command-output", + is_flag=True, + default=False, + help="Whether to redirect command output to a file.", +) +@click.option( + "--use-login-shells/--use-normal-shells", + is_flag=True, + default=True, + help=( + "Ray uses login shells (bash --login -i) to run cluster commands " + "by default. If your workflow is compatible with normal shells, " + "this can be disabled for a better user experience." + ), +) +@click.option( + "--disable-usage-stats", + is_flag=True, + default=False, + help="If True, the usage stats collection will be disabled.", +) +@add_click_logging_options +@PublicAPI +def up( + cluster_config_file, + min_workers, + max_workers, + no_restart, + restart_only, + yes, + cluster_name, + no_config_cache, + redirect_command_output, + use_login_shells, + disable_usage_stats, +): + """Create or update a Ray cluster.""" + if disable_usage_stats: + usage_lib.set_usage_stats_enabled_via_env_var(False) + + if restart_only or no_restart: + cli_logger.doassert( + restart_only != no_restart, + "`{}` is incompatible with `{}`.", + cf.bold("--restart-only"), + cf.bold("--no-restart"), + ) + assert ( + restart_only != no_restart + ), "Cannot set both 'restart_only' and 'no_restart' at the same time!" + + if urllib.parse.urlparse(cluster_config_file).scheme in ("http", "https"): + try: + response = urllib.request.urlopen(cluster_config_file, timeout=5) + content = response.read() + file_name = cluster_config_file.split("/")[-1] + with open(file_name, "wb") as f: + f.write(content) + cluster_config_file = file_name + except urllib.error.HTTPError as e: + cli_logger.warning("{}", str(e)) + cli_logger.warning("Could not download remote cluster configuration file.") + create_or_update_cluster( + config_file=cluster_config_file, + override_min_workers=min_workers, + override_max_workers=max_workers, + no_restart=no_restart, + restart_only=restart_only, + yes=yes, + override_cluster_name=cluster_name, + no_config_cache=no_config_cache, + redirect_command_output=redirect_command_output, + use_login_shells=use_login_shells, + ) + + +@cli.command() +@click.argument("cluster_config_file", required=True, type=str) +@click.option( + "--yes", "-y", is_flag=True, default=False, help="Don't ask for confirmation." +) +@click.option( + "--workers-only", is_flag=True, default=False, help="Only destroy the workers." +) +@click.option( + "--cluster-name", + "-n", + required=False, + type=str, + help="Override the configured cluster name.", +) +@click.option( + "--keep-min-workers", + is_flag=True, + default=False, + help="Retain the minimal amount of workers specified in the config.", +) +@add_click_logging_options +@PublicAPI +def down(cluster_config_file, yes, workers_only, cluster_name, keep_min_workers): + """Tear down a Ray cluster.""" + teardown_cluster( + cluster_config_file, yes, workers_only, cluster_name, keep_min_workers + ) + + +@cli.command(hidden=True) +@click.argument("cluster_config_file", required=True, type=str) +@click.option( + "--yes", "-y", is_flag=True, default=False, help="Don't ask for confirmation." +) +@click.option( + "--hard", + is_flag=True, + default=False, + help="Terminates the node via node provider (defaults to a 'soft kill'" + " which terminates Ray but does not actually delete the instances).", +) +@click.option( + "--cluster-name", + "-n", + required=False, + type=str, + help="Override the configured cluster name.", +) +def kill_random_node(cluster_config_file, yes, hard, cluster_name): + """Kills a random Ray node. For testing purposes only.""" + click.echo( + "Killed node with IP " + kill_node(cluster_config_file, yes, hard, cluster_name) + ) + + +@cli.command() +@click.argument("cluster_config_file", required=True, type=str) +@click.option( + "--lines", required=False, default=100, type=int, help="Number of lines to tail." +) +@click.option( + "--cluster-name", + "-n", + required=False, + type=str, + help="Override the configured cluster name.", +) +@add_click_logging_options +def monitor(cluster_config_file, lines, cluster_name): + """Tails the autoscaler logs of a Ray cluster.""" + monitor_cluster(cluster_config_file, lines, cluster_name) + + +@cli.command() +@click.argument("cluster_config_file", required=True, type=str) +@click.option( + "--start", is_flag=True, default=False, help="Start the cluster if needed." +) +@click.option( + "--screen", is_flag=True, default=False, help="Run the command in screen." +) +@click.option("--tmux", is_flag=True, default=False, help="Run the command in tmux.") +@click.option( + "--cluster-name", + "-n", + required=False, + type=str, + help="Override the configured cluster name.", +) +@click.option( + "--no-config-cache", + is_flag=True, + default=False, + help="Disable the local cluster config cache.", +) +@click.option("--new", "-N", is_flag=True, help="Force creation of a new screen.") +@click.option( + "--port-forward", + "-p", + required=False, + multiple=True, + type=int, + help="Port to forward. Use this multiple times to forward multiple ports.", +) +@add_click_logging_options +@PublicAPI +def attach( + cluster_config_file, + start, + screen, + tmux, + cluster_name, + no_config_cache, + new, + port_forward, +): + """Create or attach to a SSH session to a Ray cluster.""" + port_forward = [(port, port) for port in list(port_forward)] + attach_cluster( + cluster_config_file, + start, + screen, + tmux, + cluster_name, + no_config_cache=no_config_cache, + new=new, + port_forward=port_forward, + ) + + +@cli.command() +@click.argument("cluster_config_file", required=True, type=str) +@click.argument("source", required=False, type=str) +@click.argument("target", required=False, type=str) +@click.option( + "--cluster-name", + "-n", + required=False, + type=str, + help="Override the configured cluster name.", +) +@add_click_logging_options +def rsync_down(cluster_config_file, source, target, cluster_name): + """Download specific files from a Ray cluster.""" + rsync(cluster_config_file, source, target, cluster_name, down=True) + + +@cli.command() +@click.argument("cluster_config_file", required=True, type=str) +@click.argument("source", required=False, type=str) +@click.argument("target", required=False, type=str) +@click.option( + "--cluster-name", + "-n", + required=False, + type=str, + help="Override the configured cluster name.", +) +@click.option( + "--all-nodes", + "-A", + is_flag=True, + required=False, + help="Upload to all nodes (workers and head).", +) +@add_click_logging_options +def rsync_up(cluster_config_file, source, target, cluster_name, all_nodes): + """Upload specific files to a Ray cluster.""" + if all_nodes: + cli_logger.warning( + "WARNING: the `all_nodes` option is deprecated and will be " + "removed in the future. " + "Rsync to worker nodes is not reliable since workers may be " + "added during autoscaling. Please use the `file_mounts` " + "feature instead for consistent file sync in autoscaling clusters" + ) + + rsync( + cluster_config_file, + source, + target, + cluster_name, + down=False, + all_nodes=all_nodes, + ) + + +@cli.command(context_settings={"ignore_unknown_options": True}) +@click.argument("cluster_config_file", required=True, type=str) +@click.option( + "--stop", + is_flag=True, + default=False, + help="Stop the cluster after the command finishes running.", +) +@click.option( + "--start", is_flag=True, default=False, help="Start the cluster if needed." +) +@click.option( + "--screen", is_flag=True, default=False, help="Run the command in a screen." +) +@click.option("--tmux", is_flag=True, default=False, help="Run the command in tmux.") +@click.option( + "--cluster-name", + "-n", + required=False, + type=str, + help="Override the configured cluster name.", +) +@click.option( + "--no-config-cache", + is_flag=True, + default=False, + help="Disable the local cluster config cache.", +) +@click.option( + "--port-forward", + "-p", + required=False, + multiple=True, + type=int, + help="Port to forward. Use this multiple times to forward multiple ports.", +) +@click.argument("script", required=True, type=str) +@click.option( + "--args", + required=False, + type=str, + help="(deprecated) Use '-- --arg1 --arg2' for script args.", +) +@click.argument("script_args", nargs=-1) +@click.option( + "--disable-usage-stats", + is_flag=True, + default=False, + help="If True, the usage stats collection will be disabled.", +) +@click.option( + "--extra-screen-args", + default=None, + help="if screen is enabled, add the provided args to it. A useful example " + "usage scenario is passing --extra-screen-args='-Logfile /full/path/blah_log.txt'" + " as it redirects screen output also to a custom file", +) +@add_click_logging_options +def submit( + cluster_config_file, + screen, + tmux, + stop, + start, + cluster_name, + no_config_cache, + port_forward, + script, + args, + script_args, + disable_usage_stats, + extra_screen_args: Optional[str] = None, +): + """Uploads and runs a script on the specified cluster. + + The script is automatically synced to the following location: + + os.path.join("~", os.path.basename(script)) + + Example: + ray submit [CLUSTER.YAML] experiment.py -- --smoke-test + """ + cli_logger.doassert( + not (screen and tmux), + "`{}` and `{}` are incompatible.", + cf.bold("--screen"), + cf.bold("--tmux"), + ) + cli_logger.doassert( + not (script_args and args), + "`{0}` and `{1}` are incompatible. Use only `{1}`.\nExample: `{2}`", + cf.bold("--args"), + cf.bold("-- <args ...>"), + cf.bold("ray submit script.py -- --arg=123 --flag"), + ) + + assert not (screen and tmux), "Can specify only one of `screen` or `tmux`." + assert not (script_args and args), "Use -- --arg1 --arg2 for script args." + + if (extra_screen_args is not None) and (not screen): + cli_logger.abort( + "To use extra_screen_args, it is required to use the --screen flag" + ) + + if args: + cli_logger.warning( + "`{}` is deprecated and will be removed in the future.", cf.bold("--args") + ) + cli_logger.warning( + "Use `{}` instead. Example: `{}`.", + cf.bold("-- <args ...>"), + cf.bold("ray submit script.py -- --arg=123 --flag"), + ) + cli_logger.newline() + + if start: + if disable_usage_stats: + usage_lib.set_usage_stats_enabled_via_env_var(False) + + create_or_update_cluster( + config_file=cluster_config_file, + override_min_workers=None, + override_max_workers=None, + no_restart=False, + restart_only=False, + yes=True, + override_cluster_name=cluster_name, + no_config_cache=no_config_cache, + redirect_command_output=False, + use_login_shells=True, + ) + target = os.path.basename(script) + target = os.path.join("~", target) + rsync( + cluster_config_file, + script, + target, + cluster_name, + no_config_cache=no_config_cache, + down=False, + ) + + command_parts = ["python", target] + if script_args: + command_parts += list(script_args) + elif args is not None: + command_parts += [args] + + port_forward = [(port, port) for port in list(port_forward)] + cmd = " ".join(command_parts) + exec_cluster( + cluster_config_file, + cmd=cmd, + run_env="docker", + screen=screen, + tmux=tmux, + stop=stop, + start=False, + override_cluster_name=cluster_name, + no_config_cache=no_config_cache, + port_forward=port_forward, + extra_screen_args=extra_screen_args, + ) + + +@cli.command() +@click.argument("cluster_config_file", required=True, type=str) +@click.argument("cmd", required=True, type=str) +@click.option( + "--run-env", + required=False, + type=click.Choice(RUN_ENV_TYPES), + default="auto", + help="Choose whether to execute this command in a container or directly on" + " the cluster head. Only applies when docker is configured in the YAML.", +) +@click.option( + "--stop", + is_flag=True, + default=False, + help="Stop the cluster after the command finishes running.", +) +@click.option( + "--start", is_flag=True, default=False, help="Start the cluster if needed." +) +@click.option( + "--screen", is_flag=True, default=False, help="Run the command in a screen." +) +@click.option("--tmux", is_flag=True, default=False, help="Run the command in tmux.") +@click.option( + "--cluster-name", + "-n", + required=False, + type=str, + help="Override the configured cluster name.", +) +@click.option( + "--no-config-cache", + is_flag=True, + default=False, + help="Disable the local cluster config cache.", +) +@click.option( + "--port-forward", + "-p", + required=False, + multiple=True, + type=int, + help="Port to forward. Use this multiple times to forward multiple ports.", +) +@click.option( + "--disable-usage-stats", + is_flag=True, + default=False, + help="If True, the usage stats collection will be disabled.", +) +@add_click_logging_options +def exec( + cluster_config_file, + cmd, + run_env, + screen, + tmux, + stop, + start, + cluster_name, + no_config_cache, + port_forward, + disable_usage_stats, +): + """Execute a command via SSH on a Ray cluster.""" + port_forward = [(port, port) for port in list(port_forward)] + + if start: + if disable_usage_stats: + usage_lib.set_usage_stats_enabled_via_env_var(False) + + exec_cluster( + cluster_config_file, + cmd=cmd, + run_env=run_env, + screen=screen, + tmux=tmux, + stop=stop, + start=start, + override_cluster_name=cluster_name, + no_config_cache=no_config_cache, + port_forward=port_forward, + _allow_uninitialized_state=True, + ) + + +@cli.command() +@click.argument("cluster_config_file", required=True, type=str) +@click.option( + "--cluster-name", + "-n", + required=False, + type=str, + help="Override the configured cluster name.", +) +def get_head_ip(cluster_config_file, cluster_name): + """Return the head node IP of a Ray cluster.""" + click.echo(get_head_node_ip(cluster_config_file, cluster_name)) + + +@cli.command() +@click.argument("cluster_config_file", required=True, type=str) +@click.option( + "--cluster-name", + "-n", + required=False, + type=str, + help="Override the configured cluster name.", +) +def get_worker_ips(cluster_config_file, cluster_name): + """Return the list of worker IPs of a Ray cluster.""" + worker_ips = get_worker_node_ips(cluster_config_file, cluster_name) + click.echo("\n".join(worker_ips)) + + +@cli.command() +def disable_usage_stats(): + """Disable usage stats collection. + + This will not affect the current running clusters + but clusters launched in the future. + """ + usage_lib.set_usage_stats_enabled_via_config(enabled=False) + print( + "Usage stats disabled for future clusters. " + "Restart any current running clusters for this to take effect." + ) + + +@cli.command() +def enable_usage_stats(): + """Enable usage stats collection. + + This will not affect the current running clusters + but clusters launched in the future. + """ + usage_lib.set_usage_stats_enabled_via_config(enabled=True) + print( + "Usage stats enabled for future clusters. " + "Restart any current running clusters for this to take effect." + ) + + +@cli.command() +def stack(): + """Take a stack dump of all Python workers on the local machine.""" + COMMAND = """ +pyspy=`which py-spy` +if [ ! -e "$pyspy" ]; then + echo "ERROR: Please 'pip install py-spy'" \ + "or 'pip install ray[default]' first." + exit 1 +fi +# Set IFS to iterate over lines instead of over words. +export IFS=" +" +# Call sudo to prompt for password before anything has been printed. +sudo true +workers=$( + ps aux | grep -E ' ray::|default_worker.py' | grep -v raylet | grep -v grep +) +for worker in $workers; do + echo "Stack dump for $worker"; + pid=`echo $worker | awk '{print $2}'`; + case "$(uname -s)" in + Linux*) native=--native;; + *) native=;; + esac + sudo $pyspy dump --pid $pid $native; + echo; +done + """ + subprocess.call(COMMAND, shell=True) + + +@cli.command() +def microbenchmark(): + """Run a local Ray microbenchmark on the current machine.""" + from ray._private.ray_perf import main + + main() + + +@cli.command() +@click.option( + "--address", + required=False, + type=str, + help="Override the Ray address to connect to.", +) +def timeline(address): + """Take a Chrome tracing timeline for a Ray cluster.""" + address = services.canonicalize_bootstrap_address_or_die(address) + logger.info(f"Connecting to Ray instance at {address}.") + ray.init(address=address) + time = datetime.today().strftime("%Y-%m-%d_%H-%M-%S") + filename = os.path.join( + ray._common.utils.get_user_temp_dir(), f"ray-timeline-{time}.json" + ) + ray.timeline(filename=filename) + size = os.path.getsize(filename) + logger.info(f"Trace file written to {filename} ({size} bytes).") + logger.info("You can open this with chrome://tracing in the Chrome browser.") + + +@cli.command() +@click.option( + "--address", required=False, type=str, help="Override the address to connect to." +) +@click.option( + "--group-by", + type=click.Choice(["NODE_ADDRESS", "STACK_TRACE"]), + default="NODE_ADDRESS", + help="Group object references by a GroupByType \ +(e.g. NODE_ADDRESS or STACK_TRACE).", +) +@click.option( + "--sort-by", + type=click.Choice(["PID", "OBJECT_SIZE", "REFERENCE_TYPE"]), + default="OBJECT_SIZE", + help="Sort object references in ascending order by a SortingType \ +(e.g. PID, OBJECT_SIZE, or REFERENCE_TYPE).", +) +@click.option( + "--units", + type=click.Choice(["B", "KB", "MB", "GB"]), + default="B", + help="Specify unit metrics for displaying object sizes \ +(e.g. B, KB, MB, GB).", +) +@click.option( + "--no-format", + is_flag=True, + type=bool, + default=True, + help="Display unformatted results. Defaults to true when \ +terminal width is less than 137 characters.", +) +@click.option( + "--stats-only", is_flag=True, default=False, help="Display plasma store stats only." +) +@click.option( + "--num-entries", + "--n", + type=int, + default=None, + help="Specify number of sorted entries per group.", +) +def memory( + address, + group_by, + sort_by, + units, + no_format, + stats_only, + num_entries, +): + """Print object references held in a Ray cluster.""" + address = services.canonicalize_bootstrap_address_or_die(address) + gcs_client = ray._raylet.GcsClient(address=address) + _check_ray_version(gcs_client) + time = datetime.now() + header = "=" * 8 + f" Object references status: {time} " + "=" * 8 + mem_stats = memory_summary( + address, + group_by, + sort_by, + units, + no_format, + stats_only, + num_entries, + ) + print(f"{header}\n{mem_stats}") + + +@cli.command() +@click.option( + "--address", required=False, type=str, help="Override the address to connect to." +) +@click.option( + "-v", + "--verbose", + required=False, + is_flag=True, + hidden=True, + help="Experimental: Display additional debuggging information.", +) +@PublicAPI +def status(address: str, verbose: bool): + """Print cluster status, including autoscaling info.""" + address = services.canonicalize_bootstrap_address_or_die(address) + gcs_client = ray._raylet.GcsClient(address=address) + _check_ray_version(gcs_client) + ray.experimental.internal_kv._initialize_internal_kv(gcs_client) + status = gcs_client.internal_kv_get(ray_constants.DEBUG_AUTOSCALING_STATUS.encode()) + error = gcs_client.internal_kv_get(ray_constants.DEBUG_AUTOSCALING_ERROR.encode()) + print(debug_status(status, error, verbose=verbose, address=address)) + + +@cli.command(hidden=True) +@click.option( + "--stream", + "-S", + required=False, + type=bool, + is_flag=True, + default=False, + help="If True, will stream the binary archive contents to stdout", +) +@click.option( + "--output", "-o", required=False, type=str, default=None, help="Output file." +) +@click.option( + "--logs/--no-logs", + is_flag=True, + default=True, + help="Collect logs from ray session dir", +) +@click.option( + "--debug-state/--no-debug-state", + is_flag=True, + default=True, + help="Collect debug_state.txt from ray session dir", +) +@click.option( + "--pip/--no-pip", is_flag=True, default=True, help="Collect installed pip packages" +) +@click.option( + "--processes/--no-processes", + is_flag=True, + default=True, + help="Collect info on running processes", +) +@click.option( + "--processes-verbose/--no-processes-verbose", + is_flag=True, + default=True, + help="Increase process information verbosity", +) +@click.option( + "--tempfile", + "-T", + required=False, + type=str, + default=None, + help="Temporary file to use", +) +def local_dump( + stream: bool = False, + output: Optional[str] = None, + logs: bool = True, + debug_state: bool = True, + pip: bool = True, + processes: bool = True, + processes_verbose: bool = False, + tempfile: Optional[str] = None, +): + """Collect local data and package into an archive. + + Usage: + + ray local-dump [--stream/--output file] + + This script is called on remote nodes to fetch their data. + """ + # This may stream data to stdout, so no printing here + get_local_dump_archive( + stream=stream, + output=output, + logs=logs, + debug_state=debug_state, + pip=pip, + processes=processes, + processes_verbose=processes_verbose, + tempfile=tempfile, + ) + + +@cli.command() +@click.argument("cluster_config_file", required=False, type=str) +@click.option( + "--host", + "-h", + required=False, + type=str, + help="Single or list of hosts, separated by comma.", +) +@click.option( + "--ssh-user", + "-U", + required=False, + type=str, + default=None, + help="Username of the SSH user.", +) +@click.option( + "--ssh-key", + "-K", + required=False, + type=str, + default=None, + help="Path to the SSH key file.", +) +@click.option( + "--docker", + "-d", + required=False, + type=str, + default=None, + help="Name of the docker container, if applicable.", +) +@click.option( + "--local", + "-L", + required=False, + type=bool, + is_flag=True, + default=None, + help="Also include information about the local node.", +) +@click.option( + "--output", "-o", required=False, type=str, default=None, help="Output file." +) +@click.option( + "--logs/--no-logs", + is_flag=True, + default=True, + help="Collect logs from ray session dir", +) +@click.option( + "--debug-state/--no-debug-state", + is_flag=True, + default=True, + help="Collect debug_state.txt from ray log dir", +) +@click.option( + "--pip/--no-pip", is_flag=True, default=True, help="Collect installed pip packages" +) +@click.option( + "--processes/--no-processes", + is_flag=True, + default=True, + help="Collect info on running processes", +) +@click.option( + "--processes-verbose/--no-processes-verbose", + is_flag=True, + default=True, + help="Increase process information verbosity", +) +@click.option( + "--tempfile", + "-T", + required=False, + type=str, + default=None, + help="Temporary file to use", +) +def cluster_dump( + cluster_config_file: Optional[str] = None, + host: Optional[str] = None, + ssh_user: Optional[str] = None, + ssh_key: Optional[str] = None, + docker: Optional[str] = None, + local: Optional[bool] = None, + output: Optional[str] = None, + logs: bool = True, + debug_state: bool = True, + pip: bool = True, + processes: bool = True, + processes_verbose: bool = False, + tempfile: Optional[str] = None, +): + """Get log data from one or more nodes. + + Best used with Ray cluster configs: + + ray cluster-dump [cluster.yaml] + + Include the --local flag to also collect and include data from the + local node. + + Missing fields will be tried to be auto-filled. + + You can also manually specify a list of hosts using the + ``--host <host1,host2,...>`` parameter. + """ + archive_path = get_cluster_dump_archive( + cluster_config_file=cluster_config_file, + host=host, + ssh_user=ssh_user, + ssh_key=ssh_key, + docker=docker, + local=local, + output=output, + logs=logs, + debug_state=debug_state, + pip=pip, + processes=processes, + processes_verbose=processes_verbose, + tempfile=tempfile, + ) + if archive_path: + click.echo(f"Created archive: {archive_path}") + else: + click.echo("Could not create archive.") + + +@cli.command(hidden=True) +@click.option( + "--address", required=False, type=str, help="Override the address to connect to." +) +def global_gc(address): + """Trigger Python garbage collection on all cluster workers.""" + ray.init(address=address) + ray._private.internal_api.global_gc() + print("Triggered gc.collect() on all workers.") + + +@cli.command(hidden=True) +@click.option( + "--address", required=False, type=str, help="Override the address to connect to." +) +@click.option( + "--node-id", + required=False, + type=str, + help="Hex ID of the worker node to be drained. Will default to current node if not provided.", +) +@click.option( + "--reason", + required=True, + type=click.Choice( + [ + item[0] + for item in autoscaler_pb2.DrainNodeReason.items() + if item[1] != autoscaler_pb2.DRAIN_NODE_REASON_UNSPECIFIED + ] + ), + help="The reason why the node will be drained.", +) +@click.option( + "--reason-message", + required=True, + type=str, + help="The detailed drain reason message.", +) +@click.option( + "--deadline-remaining-seconds", + required=False, + type=int, + default=None, + help="Inform GCS that the node to be drained will be force killed " + "after this many of seconds. " + "Default is None which means there is no deadline. " + "Note: This command doesn't actually force kill the node after the deadline, " + "it's the caller's responsibility to do that.", +) +def drain_node( + address: str, + node_id: str, + reason: str, + reason_message: str, + deadline_remaining_seconds: int, +): + """ + This is NOT a public API. + + Manually drain a worker node. + """ + # This should be before get_runtime_context() so get_runtime_context() + # doesn't start a new worker here. + address = services.canonicalize_bootstrap_address_or_die(address) + + if node_id is None: + node_id = ray.get_runtime_context().get_node_id() + deadline_timestamp_ms = 0 + if deadline_remaining_seconds is not None: + if deadline_remaining_seconds < 0: + raise click.BadParameter( + "--deadline-remaining-seconds cannot be negative, " + f"got {deadline_remaining_seconds}" + ) + deadline_timestamp_ms = (time.time_ns() // 1000000) + ( + deadline_remaining_seconds * 1000 + ) + + if ray.NodeID.from_hex(node_id) == ray.NodeID.nil(): + raise click.BadParameter(f"Invalid hex ID of a Ray node, got {node_id}") + + gcs_client = ray._raylet.GcsClient(address=address) + _check_ray_version(gcs_client) + is_accepted, rejection_error_message = gcs_client.drain_node( + node_id, + autoscaler_pb2.DrainNodeReason.Value(reason), + reason_message, + deadline_timestamp_ms, + ) + + if not is_accepted: + raise click.ClickException( + f"The drain request is not accepted: {rejection_error_message}" + ) + + +@cli.command(name="kuberay-autoscaler", hidden=True) +@click.option( + "--cluster-name", + required=True, + type=str, + help="The name of the Ray Cluster.\n" + "Should coincide with the `metadata.name` of the RayCluster CR.", +) +@click.option( + "--cluster-namespace", + required=True, + type=str, + help="The Kubernetes namespace the Ray Cluster lives in.\n" + "Should coincide with the `metadata.namespace` of the RayCluster CR.", +) +def kuberay_autoscaler(cluster_name: str, cluster_namespace: str) -> None: + """Runs the autoscaler for a Ray cluster managed by the KubeRay operator. + + `ray kuberay-autoscaler` is meant to be used as an entry point in + KubeRay cluster configs. + `ray kuberay-autoscaler` is NOT a public CLI. + """ + # Delay import to avoid introducing Ray core dependency on the Python Kubernetes + # client. + from ray.autoscaler._private.kuberay.run_autoscaler import run_kuberay_autoscaler + + run_kuberay_autoscaler(cluster_name, cluster_namespace) + + +@cli.command(name="health-check", hidden=True) +@click.option( + "--address", required=False, type=str, help="Override the address to connect to." +) +@click.option( + "--component", + required=False, + type=str, + help="Health check for a specific component. Currently supports: " + "[ray_client_server]", +) +@click.option( + "--skip-version-check", + is_flag=True, + default=False, + help="Skip comparison of GCS version with local Ray version.", +) +def healthcheck(address, component, skip_version_check): + """ + This is NOT a public API. + + Health check a Ray or a specific component. Exit code 0 is healthy. + """ + + address = services.canonicalize_bootstrap_address_or_die(address) + gcs_client = ray._raylet.GcsClient(address=address) + if not skip_version_check: + _check_ray_version(gcs_client) + + if not component: + sys.exit(0) + + report_str = gcs_client.internal_kv_get( + component.encode(), namespace=ray_constants.KV_NAMESPACE_HEALTHCHECK + ) + if not report_str: + # Status was never updated + sys.exit(1) + + report = json.loads(report_str) + + # TODO (Alex): We probably shouldn't rely on time here, but cloud providers + # have very well synchronized NTP servers, so this should be fine in + # practice. + cur_time = time.time() + report_time = float(report["time"]) + + # If the status is too old, the service has probably already died. + delta = cur_time - report_time + time_ok = delta < ray._private.ray_constants.HEALTHCHECK_EXPIRATION_S + + if time_ok: + sys.exit(0) + else: + sys.exit(1) + + +@cli.command() +@click.option("-v", "--verbose", is_flag=True) +@click.option( + "--dryrun", + is_flag=True, + help="Identifies the wheel but does not execute the installation.", +) +def install_nightly(verbose, dryrun): + """Install the latest wheels for Ray. + + This uses the same python environment as the one that Ray is currently + installed in. Make sure that there is no Ray processes on this + machine (ray stop) when running this command. + """ + raydir = os.path.abspath(os.path.dirname(ray.__file__)) + all_wheels_path = os.path.join(raydir, "nightly-wheels.yaml") + + wheels = None + if os.path.exists(all_wheels_path): + with open(all_wheels_path) as f: + wheels = yaml.safe_load(f) + + if not wheels: + raise click.ClickException( + f"Wheels not found in '{all_wheels_path}'! " + "Please visit https://docs.ray.io/en/master/installation.html to " + "obtain the latest wheels." + ) + + platform = sys.platform + py_version = "{0}.{1}".format(*sys.version_info[:2]) + + matching_wheel = None + for target_platform, wheel_map in wheels.items(): + if verbose: + print(f"Evaluating os={target_platform}, python={list(wheel_map)}") + if platform.startswith(target_platform): + if py_version in wheel_map: + matching_wheel = wheel_map[py_version] + break + if verbose: + print("Not matched.") + + if matching_wheel is None: + raise click.ClickException( + "Unable to identify a matching platform. " + "Please visit https://docs.ray.io/en/master/installation.html to " + "obtain the latest wheels." + ) + if dryrun: + print(f"Found wheel: {matching_wheel}") + else: + cmd = [sys.executable, "-m", "pip", "install", "-U", matching_wheel] + print(f"Running: {' '.join(cmd)}.") + subprocess.check_call(cmd) + + +@cli.command() +@click.option( + "--show-library-path", + "-show", + required=False, + is_flag=True, + help="Show the cpp include path and library path, if provided.", +) +@click.option( + "--generate-bazel-project-template-to", + "-gen", + required=False, + type=str, + help="The directory to generate the bazel project template to, if provided.", +) +@add_click_logging_options +def cpp(show_library_path, generate_bazel_project_template_to): + """Show the cpp library path and generate the bazel project template.""" + if sys.platform == "win32": + raise click.ClickException("Ray C++ API is not supported on Windows currently.") + + if not show_library_path and not generate_bazel_project_template_to: + raise ValueError( + "Please input at least one option of '--show-library-path'" + " and '--generate-bazel-project-template-to'." + ) + raydir = os.path.abspath(os.path.dirname(ray.__file__)) + cpp_dir = os.path.join(raydir, "cpp") + cpp_templete_dir = os.path.join(cpp_dir, "example") + include_dir = os.path.join(cpp_dir, "include") + lib_dir = os.path.join(cpp_dir, "lib") + if not os.path.isdir(cpp_dir): + raise ValueError('Please install ray with C++ API by "pip install ray[cpp]".') + if show_library_path: + cli_logger.print("Ray C++ include path {} ", cf.bold(f"{include_dir}")) + cli_logger.print("Ray C++ library path {} ", cf.bold(f"{lib_dir}")) + if generate_bazel_project_template_to: + out_dir = generate_bazel_project_template_to + # copytree expects that the dst dir doesn't exist + # so we manually delete it if it exists. + if os.path.exists(out_dir): + shutil.rmtree(out_dir) + + shutil.copytree(cpp_templete_dir, out_dir) + for filename in ["_WORKSPACE", "_BUILD.bazel", "_.bazelrc"]: + # Renames the bazel related files by removing the leading underscore. + dest_name = os.path.join(out_dir, filename[1:]) + shutil.move(os.path.join(out_dir, filename), dest_name) + + out_include_dir = os.path.join(out_dir, "thirdparty/include") + shutil.copytree(include_dir, out_include_dir) + out_lib_dir = os.path.join(out_dir, "thirdparty/lib") + shutil.copytree(lib_dir, out_lib_dir) + + cli_logger.print( + "Project template generated to {}", + cf.bold(f"{os.path.abspath(out_dir)}"), + ) + cli_logger.print("To build and run this template, run") + cli_logger.print(cf.bold(f" cd {os.path.abspath(out_dir)} && bash run.sh")) + + +@cli.command(hidden=True) +def sanity_check(): + """Run a sanity check to check that the Ray installation works. + + This is not a public API and is intended to be used by Ray developers only. + """ + + @ray.remote + def get_version() -> str: + return ray.__version__ + + v = ray.get(get_version.remote()) + assert v == ray.__version__ + cli_logger.success(f"Success! Ray version: {v}") + + +@click.group(name="metrics") +def metrics_group(): + pass + + +@metrics_group.command(name="launch-prometheus") +def launch_prometheus(): + install_and_start_prometheus.main() + + +@metrics_group.command(name="shutdown-prometheus") +def shutdown_prometheus(): + try: + requests.post("http://localhost:9090/-/quit") + except requests.exceptions.RequestException as e: + print(f"An error occurred: {e}") + sys.exit(1) + + +@cli.command(name="get-auth-token") +@click.option( + "--generate", + is_flag=True, + default=False, + help="Generate a new token if none exists", +) +def get_auth_token(generate): + """Prints the Ray authentication token to stdout when RAY_AUTH_MODE=token. + + If --generate is specified, a new token is created and saved to ~/.ray/auth_token if one does not exist. + """ + from ray._private.authentication.authentication_token_setup import ( + generate_and_save_token, + ) + from ray._raylet import ( + AuthenticationMode, + AuthenticationTokenLoader, + get_authentication_mode, + ) + + # Check if token auth mode is enabled and provide guidance if not + if get_authentication_mode() != AuthenticationMode.TOKEN: + raise click.ClickException( + "Token authentication is not currently enabled. To enable token authentication, set: export RAY_AUTH_MODE=token\n For more instructions, see: https://docs.ray.io/en/latest/ray-security/auth.html", + ) + + # Try to load existing token + loader = AuthenticationTokenLoader.instance() + + if not loader.has_token(): + if generate: + click.echo("Generating new authentication token...", err=True) + generate_and_save_token() + loader.reset_cache() + else: + raise click.ClickException( + "No authentication token found. Use ray `get-auth-token --generate` to create one.", + ) + + # Get raw token value + token = loader.get_raw_token() + + # Print token to stdout (for piping) without newline + click.echo(token, nl=False) + # Print newline to stderr for clean terminal display (doesn't affect piping) + click.echo("", err=True) + + +def add_command_alias(command, name, hidden): + new_command = copy.deepcopy(command) + new_command.hidden = hidden + cli.add_command(new_command, name=name) + + +cli.add_command(dashboard) +cli.add_command(debug) +cli.add_command(start) +cli.add_command(stop) +cli.add_command(up) +add_command_alias(up, name="create_or_update", hidden=True) +cli.add_command(attach) +cli.add_command(exec) +add_command_alias(exec, name="exec_cmd", hidden=True) +add_command_alias(rsync_down, name="rsync_down", hidden=True) +add_command_alias(rsync_up, name="rsync_up", hidden=True) +cli.add_command(submit) +cli.add_command(down) +add_command_alias(down, name="teardown", hidden=True) +cli.add_command(kill_random_node) +add_command_alias(get_head_ip, name="get_head_ip", hidden=True) +cli.add_command(get_worker_ips) +cli.add_command(microbenchmark) +cli.add_command(stack) +cli.add_command(status) +cli.add_command(memory) +cli.add_command(local_dump) +cli.add_command(cluster_dump) +cli.add_command(global_gc) +cli.add_command(timeline) +cli.add_command(install_nightly) +cli.add_command(cpp) +cli.add_command(disable_usage_stats) +cli.add_command(enable_usage_stats) +cli.add_command(metrics_group) +cli.add_command(drain_node) +cli.add_command(check_open_ports) +cli.add_command(sanity_check) +cli.add_command(symmetric_run, name="symmetric-run") +cli.add_command(get_auth_token) + +try: + from ray.util.state.state_cli import ( + logs_state_cli_group, + ray_get, + ray_list, + summary_state_cli_group, + ) + + cli.add_command(ray_list, name="list") + cli.add_command(ray_get, name="get") + add_command_alias(summary_state_cli_group, name="summary", hidden=False) + add_command_alias(logs_state_cli_group, name="logs", hidden=False) +except ImportError as e: + logger.debug(f"Integrating ray state command line tool failed: {e}") + + +try: + from ray.dashboard.modules.job.cli import job_cli_group + + add_command_alias(job_cli_group, name="job", hidden=False) +except Exception as e: + logger.debug(f"Integrating ray jobs command line tool failed with {e}") + + +try: + from ray.serve.scripts import serve_cli + + cli.add_command(serve_cli) +except Exception as e: + logger.debug(f"Integrating ray serve command line tool failed with {e}") + + +def main(): + return cli() + + +if __name__ == "__main__": + main() diff --git a/lib/python3.12/site-packages/ray/scripts/symmetric_run.py b/lib/python3.12/site-packages/ray/scripts/symmetric_run.py new file mode 100644 index 0000000000000000000000000000000000000000..7284f78f5db4c0e6b80582844ef1fd31c4aeed5f --- /dev/null +++ b/lib/python3.12/site-packages/ray/scripts/symmetric_run.py @@ -0,0 +1,269 @@ +"""Symmetric Run for Ray.""" + +import socket +import subprocess +import sys +import time +from typing import List + +import click + +import ray +from ray._private.ray_constants import env_integer +from ray._raylet import GcsClient + +import psutil + +CLUSTER_WAIT_TIMEOUT = env_integer("RAY_SYMMETRIC_RUN_CLUSTER_WAIT_TIMEOUT", 30) + + +def check_ray_already_started() -> bool: + import ray._private.services as services + + # Try auto-detecting the Ray instance. + running_gcs_addresses = services.find_gcs_addresses() + return len(running_gcs_addresses) > 0 + + +def check_cluster_ready(nnodes, timeout=CLUSTER_WAIT_TIMEOUT): + """Wait for all nodes to start. + + Raises an exception if the nodes don't start in time. + """ + start_time = time.time() + current_nodes = 1 + ray.init(ignore_reinit_error=True) + + while time.time() - start_time < timeout: + time.sleep(5) + current_nodes = len(ray.nodes()) + if current_nodes == nnodes: + return True + else: + click.echo( + f"Waiting for nodes to start... {current_nodes}/{nnodes} nodes started" + ) + return False + + +def check_head_node_ready(address: str, timeout=CLUSTER_WAIT_TIMEOUT): + start_time = time.time() + gcs_client = GcsClient(address=address) + while time.time() - start_time < timeout: + if gcs_client.check_alive([], timeout=1): + click.echo("Ray cluster is ready!") + return True + time.sleep(5) + return False + + +def curate_and_validate_ray_start_args(run_and_start_args: List[str]) -> List[str]: + # Reparse the arguments to remove symmetric_run arguments. + ctx = symmetric_run.make_context("_", run_and_start_args, resilient_parsing=True) + cleaned_args = list(ctx.params["ray_args_and_entrypoint"]) + + for arg in cleaned_args: + if arg == "--head": + raise click.ClickException("Cannot use --head option in symmetric_run.") + if arg == "--node-ip-address": + raise click.ClickException( + "Cannot use --node-ip-address option in symmetric_run." + ) + if arg == "--port": + raise click.ClickException("Cannot use --port option in symmetric_run.") + if arg == "--block": + raise click.ClickException("Cannot use --block option in symmetric_run.") + + return cleaned_args + + +@click.command( + name="symmetric_run", + context_settings={"ignore_unknown_options": True, "allow_extra_args": True}, + help="""Command to start Ray across all nodes and execute an entrypoint command. + +USAGE: + + ray symmetric-run --address ADDRESS +[--min-nodes NUM_NODES] [RAY_START_OPTIONS] -- [ENTRYPOINT_COMMAND] + +DESCRIPTION: + + This command (1) starts a Ray cluster across all nodes, +(2) runs a command on the head node, and (3) stops the Ray cluster. + + The '--' separator is required to distinguish between Ray start arguments +and the entrypoint command. The --min-nodes option is optional and +can be used to wait for a specific number of nodes to start. + +EXAMPLES: + + # Start Ray with default settings and run a Python script + + ray symmetric-run --address 127.0.0.1:6379 -- python my_script.py + + # Start Ray with specific head node and run a command + + ray symmetric-run --address 127.0.0.1:6379 --min-nodes 4 -- python train_model.py --epochs=100 + + # Start Ray and run a multi-word command + + ray symmetric-run --address 127.0.0.1:6379 --min-nodes 4 --num-cpus=4 -- python -m my_module --config=prod + +RAY START OPTIONS: + + Most ray start command options are supported. Arguments that are not +supported are: --head, --node-ip-address, --port, --block. + +SEPARATOR REQUIREMENT: + + The '--' separator is mandatory and must appear between Ray start + arguments and the entrypoint command. This ensures clear separation + between the two sets of arguments. +""", +) +@click.option( + "--address", required=True, type=str, help="The address of the Ray cluster." +) +@click.option( + "--min-nodes", + type=int, + help="If provided, wait for this number of nodes to start.", +) +@click.argument("ray_args_and_entrypoint", nargs=-1, type=click.UNPROCESSED) +def symmetric_run(address, min_nodes, ray_args_and_entrypoint): + all_args = sys.argv[1:] + + if all_args and all_args[0] == "symmetric-run": + all_args = all_args[1:] + + try: + separator = all_args.index("--") + except ValueError: + raise click.ClickException( + "No separator '--' found in arguments. Please use '--' to " + "separate Ray start arguments and the entrypoint command." + ) + + run_and_start_args, entrypoint_on_head = ( + all_args[:separator], + all_args[separator + 1 :], + ) + + ray_start_args = curate_and_validate_ray_start_args(run_and_start_args) + + min_nodes = 1 if min_nodes is None else min_nodes + + if not entrypoint_on_head: + raise click.ClickException("No entrypoint command provided.") + + if check_ray_already_started(): + raise click.ClickException("Ray is already started on this node.") + + # 1. Parse address and check if we are on the head node. + gcs_host_port = ray._common.network_utils.parse_address(address) + if gcs_host_port is None: + raise click.ClickException( + f"Invalid address format: {address}, should be `host:port`" + ) + gcs_host, gcs_port = gcs_host_port + + try: + # AF_UNSPEC allows resolving both IPv4 and IPv6 + addrinfo = socket.getaddrinfo( + gcs_host, gcs_port, socket.AF_UNSPEC, socket.SOCK_STREAM + ) + resolved_gcs_host = addrinfo[0][4][0] + except socket.gaierror: + raise click.ClickException(f"Could not resolve hostname: {gcs_host}") + + my_ips = [] + for iface, addrs in psutil.net_if_addrs().items(): + for addr in addrs: + # Look for AF_INET (IPv4) or AF_INET6 (IPv6) + if addr.family in [ + socket.AddressFamily.AF_INET, + socket.AddressFamily.AF_INET6, + ]: + my_ips.append(addr.address) + + if min_nodes > 1: + # Ban localhost ips if we are not running on a single node + # to avoid starting N head nodes + my_ips = [ip for ip in my_ips if ip != "127.0.0.1" and ip != "::1"] + + is_head = resolved_gcs_host in my_ips + + result = None + # 2. Start Ray and run commands. + try: + if is_head: + # On the head node, start Ray, run the command, then stop Ray. + click.echo("On head node. Starting Ray cluster head...") + + # Build the ray start command with all parameters + ray_start_cmd = [ + "ray", + "start", + "--head", + f"--node-ip-address={resolved_gcs_host}", + f"--port={gcs_port}", + *ray_start_args, + ] + + # Start Ray head. This runs in the background and hides output. + subprocess.run(ray_start_cmd, check=True, capture_output=True) + click.echo("Head node started.") + click.echo("=======================") + if min_nodes > 1 and not check_cluster_ready(min_nodes): + raise click.ClickException( + "Timed out waiting for other nodes to start." + ) + + click.echo( + f"Running command on head node: {entrypoint_on_head}", + ) + click.echo("=======================") + result = subprocess.run(entrypoint_on_head) + click.echo("=======================") + else: + # On a worker node, start Ray and connect to the head. + click.echo(f"On worker node. Connecting to Ray cluster at {address}...") + + if not check_head_node_ready(address): + raise click.ClickException("Timed out waiting for head node to start.") + + # Build the ray start command for worker nodes with all parameters + ray_start_cmd = [ + "ray", + "start", + "--address", + address, + "--block", + *ray_start_args, + ] + + # This command will block until the Ray cluster is stopped. + subprocess.run(ray_start_cmd, check=True) + + except subprocess.CalledProcessError as e: + click.echo(f"Failed to start Ray: {e}", err=True) + if e.stdout: + click.echo(f"stdout:\n{e.stdout.decode()}", err=True) + if e.stderr: + click.echo(f"stderr:\n{e.stderr.decode()}", err=True) + except KeyboardInterrupt: + # This can be triggered by ctrl-c on the user's side. + click.echo("Interrupted by user.", err=True) + finally: + # Stop Ray cluster. + subprocess.run(["ray", "stop"]) + + # Propagate the exit code of the user script. + if result is not None and result.returncode != 0: + click.echo(f"Command failed with return code {result.returncode}", err=True) + sys.exit(result.returncode) + + +if __name__ == "__main__": + symmetric_run() diff --git a/lib/python3.12/site-packages/ray/serve/__init__.py b/lib/python3.12/site-packages/ray/serve/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..379b118ac1f51651ffe40c91cbe9531538c6ba66 --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/__init__.py @@ -0,0 +1,65 @@ +import ray._private.worker + +try: + from ray.serve._private.logging_utils import configure_default_serve_logger + from ray.serve.api import ( + Application, + Deployment, + RunTarget, + _run, + _run_many, + delete, + deployment, + get_app_handle, + get_deployment_handle, + get_multiplexed_model_id, + get_replica_context, + ingress, + multiplexed, + run, + run_many, + shutdown, + shutdown_async, + start, + status, + ) + from ray.serve.batching import batch + from ray.serve.config import HTTPOptions + +except ModuleNotFoundError as e: + e.msg += ( + '. You can run `pip install "ray[serve]"` to install all Ray Serve' + " dependencies." + ) + raise e + +# Setup default ray.serve logger to ensure all serve module logs are captured. +configure_default_serve_logger() + +# Mute the warning because Serve sometimes intentionally calls +# ray.get inside async actors. +ray._private.worker.blocking_get_inside_async_warned = True + +__all__ = [ + "_run", + "_run_many", + "batch", + "start", + "HTTPOptions", + "get_replica_context", + "shutdown", + "shutdown_async", + "ingress", + "deployment", + "run", + "run_many", + "RunTarget", + "delete", + "Application", + "Deployment", + "multiplexed", + "get_multiplexed_model_id", + "status", + "get_app_handle", + "get_deployment_handle", +] diff --git a/lib/python3.12/site-packages/ray/serve/__pycache__/__init__.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..65bbdf7fe545210d8e02c694cff3d75bb95ef74e Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/__pycache__/__init__.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/__pycache__/api.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/__pycache__/api.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e0bde8fc54c7886ee927cd3c022be3361a143b72 Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/__pycache__/api.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/__pycache__/autoscaling_policy.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/__pycache__/autoscaling_policy.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f596e8ad282ae001d9a9275e8162e523bff5a667 Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/__pycache__/autoscaling_policy.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/__pycache__/batching.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/__pycache__/batching.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..93ccb8c7249a429276027a43c757630444ca58ed Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/__pycache__/batching.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/__pycache__/config.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/__pycache__/config.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bfb41475fd73dd0ded455ff887b5f2970b99662b Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/__pycache__/config.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/__pycache__/context.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/__pycache__/context.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bd7339f60d47c549a8a5b2a345334ab7afdfe7dc Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/__pycache__/context.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/__pycache__/dag.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/__pycache__/dag.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a0832d063b61fb45627e4cfc9f126b34762ec024 Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/__pycache__/dag.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/__pycache__/deployment.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/__pycache__/deployment.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..017e958c9a961bc7ca7e5415eeead3fe49d10729 Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/__pycache__/deployment.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/__pycache__/exceptions.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/__pycache__/exceptions.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0cc489d361757389225c80862747d855b3c540bc Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/__pycache__/exceptions.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/__pycache__/gradio_integrations.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/__pycache__/gradio_integrations.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4171209262cf028166097f8a85dfc8d05dbc0118 Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/__pycache__/gradio_integrations.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/__pycache__/grpc_util.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/__pycache__/grpc_util.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e23704cb9db5562515e9193721277679464f0ef1 Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/__pycache__/grpc_util.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/__pycache__/handle.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/__pycache__/handle.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..787733d592dd665d3c03e89f6c70819d29381c5d Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/__pycache__/handle.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/__pycache__/metrics.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/__pycache__/metrics.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c468d01276728821c6ecc949fe296f1711fbd6cf Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/__pycache__/metrics.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/__pycache__/multiplex.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/__pycache__/multiplex.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..47d7793030727e36b5821969f4fde26790acf63c Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/__pycache__/multiplex.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/__pycache__/request_router.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/__pycache__/request_router.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..db0c40b4179c0698cf7f40b96e48f0471db9df47 Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/__pycache__/request_router.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/__pycache__/schema.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/__pycache__/schema.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7b7e5a2138bf0a6cb6d147f7141566633e71d0b7 Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/__pycache__/schema.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/__pycache__/scripts.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/__pycache__/scripts.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f4eb0a32da9c569afed3f92d3ba6b5d4b7079708 Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/__pycache__/scripts.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/__pycache__/task_consumer.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/__pycache__/task_consumer.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c5f9a0150e1a09c3a5469902e40abb441d772909 Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/__pycache__/task_consumer.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/__pycache__/task_processor.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/__pycache__/task_processor.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..007a54f52525999e22acf08a191fb8126be43984 Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/__pycache__/task_processor.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/_private/__init__.py b/lib/python3.12/site-packages/ray/serve/_private/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lib/python3.12/site-packages/ray/serve/_private/__pycache__/__init__.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9b4a63628a10eefcce6314bf70e1aa06c78e8bef Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/__init__.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/_private/__pycache__/api.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/api.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a371d8c8286205dcff8e30bd8f54e34f13a41d5d Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/api.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/_private/__pycache__/application_state.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/application_state.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6586a94c995b4f33fcb55ea5983e7e18255e661e Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/application_state.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/_private/__pycache__/autoscaling_state.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/autoscaling_state.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..aa159b40d34fb849d11d44e26001f50317383874 Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/autoscaling_state.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/_private/__pycache__/build_app.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/build_app.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e1aa6923fd53cae33e203ed41397492634fc353c Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/build_app.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/_private/__pycache__/client.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/client.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..491491184bdb9e2fc65194fdc76972a73a29942e Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/client.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/_private/__pycache__/cluster_node_info_cache.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/cluster_node_info_cache.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..778b08bbf0a6b42c3f4f886c6ff7a0fc2a5e0af7 Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/cluster_node_info_cache.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/_private/__pycache__/common.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/common.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9333852e51dc3ac94a8bbfeba9650e02eb1cfe2f Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/common.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/_private/__pycache__/config.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/config.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..669a1735736756b82bf8b5b1372ae362900a11c1 Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/config.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/_private/__pycache__/constants.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/constants.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..da435db0dd7cb8b54b310673f545f486b34fc0fa Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/constants.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/_private/__pycache__/constants_utils.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/constants_utils.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..dd4d5007ff595e04eafa657ce4d45a16ff8389f9 Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/constants_utils.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/_private/__pycache__/controller.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/controller.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8cfd66823c2c91214308ce54b9b36288d6f20154 Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/controller.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/_private/__pycache__/controller_avatar.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/controller_avatar.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..74d20ad85cb4dfde5c9f7ef6907b0d0ada209227 Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/controller_avatar.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/_private/__pycache__/default_impl.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/default_impl.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..dc81c0a032623a3e122ade2469debafb08ab283f Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/default_impl.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/_private/__pycache__/deploy_utils.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/deploy_utils.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4bf1359a37fde7b6def5ed3563299851502f70f8 Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/deploy_utils.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/_private/__pycache__/deployment_info.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/deployment_info.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b5731589c002f577bef5bf833098bed109ce6dfc Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/deployment_info.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/_private/__pycache__/deployment_node.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/deployment_node.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ceaf76677b853b3b494c0eb8324b033bfa28a494 Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/deployment_node.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/_private/__pycache__/deployment_scheduler.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/deployment_scheduler.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2bd31f279bab295ab94264be3643f6715cee2960 Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/deployment_scheduler.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/_private/__pycache__/endpoint_state.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/endpoint_state.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..137d04074d7802e4592783b599890a976b0a09c0 Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/endpoint_state.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/_private/__pycache__/exceptions.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/exceptions.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8904452b50f3e6c661b89b0acaec1918b22d4fde Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/exceptions.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/_private/__pycache__/grpc_util.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/grpc_util.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..99a45fb87ab50c27b4414cb8695446e6baa1e98f Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/grpc_util.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/_private/__pycache__/handle_options.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/handle_options.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e3b68873c48ed93a6506c9ad7a19154e4fa49cc3 Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/handle_options.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/_private/__pycache__/http_util.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/http_util.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f0e30995e85a154b5d069458d0cf6a7463ee51c3 Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/http_util.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/_private/__pycache__/local_testing_mode.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/local_testing_mode.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fce001790a2347f0d58ddc06ee6bf1fbaf78b47c Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/local_testing_mode.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/_private/__pycache__/logging_utils.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/logging_utils.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..96240ac5c1ded1550d9aaa1b2f6b1abc5a0caafc Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/logging_utils.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/_private/__pycache__/long_poll.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/long_poll.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b5f9be587879345af5c17de8142849bbbd2499e5 Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/long_poll.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/_private/__pycache__/metrics_utils.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/metrics_utils.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bc2640985dc14349ff17b20449a88035566a2f3c Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/metrics_utils.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/_private/__pycache__/proxy.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/proxy.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..378abb8a31d2edef812a93b70391100216167a8f Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/proxy.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/_private/__pycache__/proxy_request_response.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/proxy_request_response.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6dc4aeb47534f7f62f35180e9c24beb48fa45b66 Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/proxy_request_response.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/_private/__pycache__/proxy_response_generator.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/proxy_response_generator.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..eb16bce5db8c2c763741368c343db092d393caca Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/proxy_response_generator.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/_private/__pycache__/proxy_router.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/proxy_router.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..aedb6789ed60901dc0ca968b72cdaa70ac8936e9 Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/proxy_router.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/_private/__pycache__/proxy_state.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/proxy_state.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b4b7d604600f5a7886ab2da514184b0472e4c1d0 Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/proxy_state.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/_private/__pycache__/replica.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/replica.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..542bee548487862bcd5398ab08fd2ec5a49f4fb2 Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/replica.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/_private/__pycache__/replica_result.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/replica_result.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..34e1f7d54923218f1e697799b9514297a3fc1a6a Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/replica_result.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/_private/__pycache__/router.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/router.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..101274e0940acc7a9be525e817088a7ab85a066b Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/router.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/_private/__pycache__/task_consumer.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/task_consumer.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..771a02eb77734f77591eef7f9a26cc1059a494ed Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/task_consumer.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/_private/__pycache__/test_utils.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/test_utils.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..09bc2db87da2e67a6ed25e3e5b7ab219905286dd Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/test_utils.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/_private/__pycache__/usage.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/usage.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6e6b2ef15966938ef4f7ba5fb34f6be4ce6dcc6b Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/usage.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/_private/__pycache__/utils.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/utils.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..85decd6138afd4c63f32fbad38caa4c8553a8837 Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/utils.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/_private/__pycache__/version.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/version.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8260b9a4cc18d2a512290519c7818c5c01abf52c Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/_private/__pycache__/version.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/_private/api.py b/lib/python3.12/site-packages/ray/serve/_private/api.py new file mode 100644 index 0000000000000000000000000000000000000000..60b097d4c001e7716d319ae6c5dfe00cd4598eb0 --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/_private/api.py @@ -0,0 +1,281 @@ +import inspect +import logging +from types import FunctionType +from typing import Any, Dict, Union + +import ray +from ray._common.pydantic_compat import is_subclass_of_base_model +from ray._common.usage import usage_lib +from ray.actor import ActorHandle +from ray.serve._private.client import ServeControllerClient +from ray.serve._private.constants import ( + HTTP_PROXY_TIMEOUT, + SERVE_LOGGER_NAME, + SERVE_NAMESPACE, +) +from ray.serve._private.default_impl import get_controller_impl +from ray.serve.config import HTTPOptions, gRPCOptions +from ray.serve.context import _get_global_client, _set_global_client +from ray.serve.deployment import Application +from ray.serve.exceptions import RayServeException +from ray.serve.schema import LoggingConfig + +logger = logging.getLogger(SERVE_LOGGER_NAME) + + +def _check_http_options( + client: ServeControllerClient, http_options: Union[dict, HTTPOptions] +) -> None: + if http_options: + client_http_options = client.http_config + new_http_options = ( + http_options + if isinstance(http_options, HTTPOptions) + else HTTPOptions.parse_obj(http_options) + ) + different_fields = [] + all_http_option_fields = new_http_options.__dict__ + for field in all_http_option_fields: + if getattr(new_http_options, field) != getattr(client_http_options, field): + different_fields.append(field) + + if len(different_fields): + logger.warning( + "The new client HTTP config differs from the existing one " + f"in the following fields: {different_fields}. " + "The new HTTP config is ignored." + ) + + +def _start_controller( + http_options: Union[None, dict, HTTPOptions] = None, + grpc_options: Union[None, dict, gRPCOptions] = None, + global_logging_config: Union[None, dict, LoggingConfig] = None, + **kwargs, +) -> ActorHandle: + """Start Ray Serve controller. + + The function makes sure controller is ready to start deploying apps + after it returns. + + Parameters are same as ray.serve._private.api.serve_start(). + + Returns: controller actor handle. + """ + + # Initialize ray if needed. + ray._private.worker.global_worker._filter_logs_by_job = False + if not ray.is_initialized(): + ray.init(namespace=SERVE_NAMESPACE) + + # Legacy http proxy actor check + http_deprecated_args = ["http_host", "http_port", "http_middlewares"] + for key in http_deprecated_args: + if key in kwargs: + raise ValueError( + f"{key} is deprecated, please use serve.start(http_options=" + f'{{"{key}": {kwargs[key]}}}) instead.' + ) + + if isinstance(http_options, dict): + http_options = HTTPOptions.parse_obj(http_options) + if http_options is None: + http_options = HTTPOptions() + + if isinstance(grpc_options, dict): + grpc_options = gRPCOptions(**grpc_options) + + if global_logging_config is None: + global_logging_config = LoggingConfig() + elif isinstance(global_logging_config, dict): + global_logging_config = LoggingConfig(**global_logging_config) + + controller_impl = get_controller_impl() + controller = controller_impl.remote( + http_options=http_options, + grpc_options=grpc_options, + global_logging_config=global_logging_config, + ) + + proxy_handles = ray.get(controller.get_proxies.remote()) + if len(proxy_handles) > 0: + try: + ray.get( + [handle.ready.remote() for handle in proxy_handles.values()], + timeout=HTTP_PROXY_TIMEOUT, + ) + except ray.exceptions.GetTimeoutError: + raise TimeoutError( + f"HTTP proxies not available after {HTTP_PROXY_TIMEOUT}s." + ) + return controller + + +async def serve_start_async( + http_options: Union[None, dict, HTTPOptions] = None, + grpc_options: Union[None, dict, gRPCOptions] = None, + global_logging_config: Union[None, dict, LoggingConfig] = None, + **kwargs, +) -> ServeControllerClient: + """Initialize a serve instance asynchronously. + + This function is not thread-safe. The caller should maintain the async lock in order + to start the serve instance asynchronously. + + This function has the same functionality as ray.serve._private.api.serve_start(). + + Parameters & Returns are same as ray.serve._private.api.serve_start(). + """ + + usage_lib.record_library_usage("serve") + + try: + client = _get_global_client(_health_check_controller=True) + logger.info( + f'Connecting to existing Serve app in namespace "{SERVE_NAMESPACE}".' + " New http options will not be applied." + ) + if http_options: + _check_http_options(client, http_options) + return client + except RayServeException: + pass + + controller = ( + await ray.remote(_start_controller) + .options(num_cpus=0) + .remote(http_options, grpc_options, global_logging_config, **kwargs) + ) + + client = ServeControllerClient( + controller, + ) + _set_global_client(client) + logger.info(f'Started Serve in namespace "{SERVE_NAMESPACE}".') + return client + + +def serve_start( + http_options: Union[None, dict, HTTPOptions] = None, + grpc_options: Union[None, dict, gRPCOptions] = None, + global_logging_config: Union[None, dict, LoggingConfig] = None, + **kwargs, +) -> ServeControllerClient: + """Initialize a serve instance. + + By default, the instance will be scoped to the lifetime of the returned + Client object (or when the script exits). This is + only relevant if connecting to a long-running Ray cluster (e.g., with + ray.init(address="auto") or ray.init("ray://<remote_addr>")). + + Args: + http_options (Optional[Dict, serve.HTTPOptions]): Configuration options + for HTTP proxy. You can pass in a dictionary or HTTPOptions object + with fields: + + - host(str, None): Host for HTTP servers to listen on. Defaults to + "127.0.0.1". To expose Serve publicly, you probably want to set + this to "0.0.0.0". + - port(int): Port for HTTP server. Defaults to 8000. + - root_path(str): Root path to mount the serve application + (for example, "/serve"). All deployment routes will be prefixed + with this path. Defaults to "". + - middlewares(list): A list of Starlette middlewares that will be + applied to the HTTP servers in the cluster. Defaults to []. + - location(str, serve.config.DeploymentMode): The deployment + location of HTTP servers: + + - "HeadOnly": start one HTTP server on the head node. Serve + assumes the head node is the node you executed serve.start + on. This is the default. + - "EveryNode": start one HTTP server per node. + - "NoServer" or None: disable HTTP server. + - num_cpus (int): The number of CPU cores to reserve for each + internal Serve HTTP proxy actor. Defaults to 0. + grpc_options: [Experimental] Configuration options for gRPC proxy. + You can pass in a gRPCOptions object with fields: + + - port(int): Port for gRPC server. Defaults to 9000. + - grpc_servicer_functions(list): List of import paths for gRPC + `add_servicer_to_server` functions to add to Serve's gRPC proxy. + Default empty list, meaning not to start the gRPC server. + """ + + usage_lib.record_library_usage("serve") + + try: + client = _get_global_client(_health_check_controller=True) + logger.info( + f'Connecting to existing Serve app in namespace "{SERVE_NAMESPACE}".' + " New http options will not be applied." + ) + if http_options: + _check_http_options(client, http_options) + return client + except RayServeException: + pass + + controller = _start_controller( + http_options, grpc_options, global_logging_config, **kwargs + ) + + client = ServeControllerClient( + controller, + ) + _set_global_client(client) + logger.info(f'Started Serve in namespace "{SERVE_NAMESPACE}".') + return client + + +def call_user_app_builder_with_args_if_necessary( + builder: Union[Application, FunctionType], + args: Dict[str, Any], +) -> Application: + """Calls a user-provided function that returns Serve application. + + If an Application object is passed, this is a no-op. + + Else, we validate the signature of the function, convert the args dictionary to + the user-annotated Pydantic model if provided, and call the function. + + The output of the function is returned (must be an Application). + """ + if isinstance(builder, Application): + if len(args) > 0: + raise ValueError( + "Arguments can only be passed to an application builder function, " + "not an already built application." + ) + return builder + elif not isinstance(builder, FunctionType): + raise TypeError( + "Expected a built Serve application or an application builder function " + f"but got: {type(builder)}." + ) + + # Check that the builder only takes a single argument. + # TODO(edoakes): we may want to loosen this to allow optional kwargs in the future. + signature = inspect.signature(builder) + if len(signature.parameters) != 1: + raise TypeError( + "Application builder functions should take exactly one parameter, " + "a dictionary containing the passed arguments." + ) + + # If the sole argument to the builder is a pydantic model, convert the args dict to + # that model. This will perform standard pydantic validation (e.g., raise an + # exception if required fields are missing). + param = signature.parameters[list(signature.parameters.keys())[0]] + if inspect.isclass(param.annotation) and is_subclass_of_base_model( + param.annotation + ): + args = param.annotation.parse_obj(args) + + app = builder(args) + if not isinstance(app, Application): + raise TypeError( + "Application builder functions must return an `Application` returned " + f"`from `Deployment.bind()`, but got: {type(app)}." + ) + + return app diff --git a/lib/python3.12/site-packages/ray/serve/_private/application_state.py b/lib/python3.12/site-packages/ray/serve/_private/application_state.py new file mode 100644 index 0000000000000000000000000000000000000000..aabf6de82c223f01bd3c6955161f89f600dbe4d2 --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/_private/application_state.py @@ -0,0 +1,1636 @@ +import inspect +import json +import logging +import os +import time +import traceback +from copy import deepcopy +from dataclasses import asdict, dataclass, field +from enum import Enum +from typing import Dict, List, Optional, Tuple + +import ray +from ray import cloudpickle +from ray._common.utils import import_attr, import_module_and_attr +from ray.exceptions import RuntimeEnvSetupError +from ray.serve._private.autoscaling_state import AutoscalingStateManager +from ray.serve._private.build_app import BuiltApplication, build_app +from ray.serve._private.common import ( + DeploymentID, + DeploymentStatus, + DeploymentStatusInfo, + DeploymentStatusTrigger, + EndpointInfo, + TargetCapacityDirection, +) +from ray.serve._private.config import DeploymentConfig +from ray.serve._private.constants import ( + DEFAULT_AUTOSCALING_POLICY_NAME, + DEFAULT_REQUEST_ROUTER_PATH, + RAY_SERVE_ENABLE_TASK_EVENTS, + SERVE_LOGGER_NAME, +) +from ray.serve._private.deploy_utils import ( + deploy_args_to_deployment_info, + get_app_code_version, + get_deploy_args, +) +from ray.serve._private.deployment_info import DeploymentInfo +from ray.serve._private.deployment_state import DeploymentStateManager +from ray.serve._private.endpoint_state import EndpointState +from ray.serve._private.logging_utils import configure_component_logger +from ray.serve._private.storage.kv_store import KVStoreBase +from ray.serve._private.usage import ServeUsageTag +from ray.serve._private.utils import ( + DEFAULT, + check_obj_ref_ready_nowait, + override_runtime_envs_except_env_vars, + validate_route_prefix, +) +from ray.serve.api import ASGIAppReplicaWrapper +from ray.serve.config import AutoscalingConfig, AutoscalingPolicy, RequestRouterConfig +from ray.serve.exceptions import RayServeException +from ray.serve.generated.serve_pb2 import ( + ApplicationStatus as ApplicationStatusProto, + ApplicationStatusInfo as ApplicationStatusInfoProto, + DeploymentLanguage, + DeploymentStatusInfoList as DeploymentStatusInfoListProto, + StatusOverview as StatusOverviewProto, +) +from ray.serve.schema import ( + APIType, + ApplicationStatus, + DeploymentDetails, + LoggingConfig, + ServeApplicationSchema, +) +from ray.types import ObjectRef + +logger = logging.getLogger(SERVE_LOGGER_NAME) + +CHECKPOINT_KEY = "serve-application-state-checkpoint" + + +class BuildAppStatus(Enum): + """Status of the build application task.""" + + NO_TASK_IN_PROGRESS = 1 + IN_PROGRESS = 2 + SUCCEEDED = 3 + FAILED = 4 + + +@dataclass +class BuildAppTaskInfo: + """Stores info on the current in-progress build app task. + + We use a class instead of only storing the task object ref because + when a new config is deployed, there can be an outdated in-progress + build app task. We attach the code version to the task info to + distinguish outdated build app tasks. + """ + + obj_ref: ObjectRef + code_version: str + config: ServeApplicationSchema + target_capacity: Optional[float] + target_capacity_direction: Optional[TargetCapacityDirection] + finished: bool + + +@dataclass(eq=True) +class ApplicationStatusInfo: + status: ApplicationStatus + message: str = "" + deployment_timestamp: float = 0 + + def debug_string(self): + return json.dumps(asdict(self), indent=4) + + def to_proto(self): + return ApplicationStatusInfoProto( + status=f"APPLICATION_STATUS_{self.status.name}", + message=self.message, + deployment_timestamp=self.deployment_timestamp, + ) + + @classmethod + def from_proto(cls, proto: ApplicationStatusInfoProto): + status = ApplicationStatusProto.Name(proto.status)[len("APPLICATION_STATUS_") :] + return cls( + status=ApplicationStatus(status), + message=proto.message, + deployment_timestamp=proto.deployment_timestamp, + ) + + +@dataclass(eq=True) +class StatusOverview: + app_status: ApplicationStatusInfo + name: str = "" + deployment_statuses: List[DeploymentStatusInfo] = field(default_factory=list) + + def debug_string(self): + return json.dumps(asdict(self), indent=4) + + def get_deployment_status(self, name: str) -> Optional[DeploymentStatusInfo]: + """Get a deployment's status by name. + + Args: + name: Deployment's name. + + Returns: + Optional[DeploymentStatusInfo]: The status of the deployment if it exists, + otherwise None. + """ + + for deployment_status in self.deployment_statuses: + if name == deployment_status.name: + return deployment_status + + return None + + def to_proto(self): + # Create a protobuf for the Serve Application info + app_status_proto = self.app_status.to_proto() + + # Create protobufs for all individual deployment statuses + deployment_status_protos = map( + lambda status: status.to_proto(), self.deployment_statuses + ) + + # Create a protobuf list containing all the deployment status protobufs + deployment_status_proto_list = DeploymentStatusInfoListProto() + deployment_status_proto_list.deployment_status_infos.extend( + deployment_status_protos + ) + + # Return protobuf encapsulating application and deployment protos + return StatusOverviewProto( + name=self.name, + app_status=app_status_proto, + deployment_statuses=deployment_status_proto_list, + ) + + @classmethod + def from_proto(cls, proto: StatusOverviewProto) -> "StatusOverview": + # Recreate Serve Application info + app_status = ApplicationStatusInfo.from_proto(proto.app_status) + + # Recreate deployment statuses + deployment_statuses = [] + for info_proto in proto.deployment_statuses.deployment_status_infos: + deployment_statuses.append(DeploymentStatusInfo.from_proto(info_proto)) + + # Recreate StatusInfo + return cls( + app_status=app_status, + deployment_statuses=deployment_statuses, + name=proto.name, + ) + + +@dataclass +class ApplicationTargetState: + """Defines target state of application. + + Target state can become inconsistent if the code version doesn't + match that of the config. When that happens, a new build app task + should be kicked off to reconcile the inconsistency. + + deployment_infos: map of deployment name to deployment info. This is + - None if a config was deployed but the app hasn't finished + building yet, + - An empty dict if the app is deleting. + code_version: Code version of all deployments in target state. None + if application was deployed through serve.run. + config: application config deployed by user. None if application was + deployed through serve.run. + target_capacity: the target_capacity to use when adjusting num_replicas. + target_capacity_direction: the scale direction to use when + running the Serve autoscaler. + deleting: whether the application is being deleted. + serialized_application_autoscaling_policy_def: Optional[bytes] + """ + + deployment_infos: Optional[Dict[str, DeploymentInfo]] + code_version: Optional[str] + config: Optional[ServeApplicationSchema] + target_capacity: Optional[float] + target_capacity_direction: Optional[TargetCapacityDirection] + deleting: bool + api_type: APIType + serialized_application_autoscaling_policy_def: Optional[bytes] + + +class ApplicationState: + """Manage single application states with all operations""" + + def __init__( + self, + name: str, + deployment_state_manager: DeploymentStateManager, + autoscaling_state_manager: AutoscalingStateManager, + endpoint_state: EndpointState, + logging_config: LoggingConfig, + ): + """ + Initialize an ApplicationState instance. + + Args: + name: Application name. + deployment_state_manager: Manages the state of all deployments in the cluster. + autoscaling_state_manager: Manages autoscaling decisions in the cluster. + endpoint_state: Manages endpoints in the system. + logging_config: Logging configuration schema. + """ + + self._name = name + self._status_msg = "" + self._deployment_state_manager = deployment_state_manager + self._autoscaling_state_manager = autoscaling_state_manager + self._endpoint_state = endpoint_state + self._route_prefix: Optional[str] = None + self._ingress_deployment_name: Optional[str] = None + + self._status: ApplicationStatus = ApplicationStatus.DEPLOYING + self._deployment_timestamp = time.time() + + self._build_app_task_info: Optional[BuildAppTaskInfo] = None + # Before a deploy app task finishes, we don't know what the + # target deployments are, so set deployment_infos=None + self._target_state: ApplicationTargetState = ApplicationTargetState( + deployment_infos=None, + code_version=None, + config=None, + target_capacity=None, + target_capacity_direction=None, + deleting=False, + api_type=APIType.UNKNOWN, + serialized_application_autoscaling_policy_def=None, + ) + self._logging_config = logging_config + + @property + def route_prefix(self) -> Optional[str]: + return self._route_prefix + + @property + def docs_path(self) -> Optional[str]: + # get the docs path from the running deployments + # we are making an assumption that the docs path can only be set + # on ingress deployments with fastapi. + ingress_deployment = DeploymentID(self._ingress_deployment_name, self._name) + return self._deployment_state_manager.get_deployment_docs_path( + ingress_deployment + ) + + @property + def status(self) -> ApplicationStatus: + """Status of the application. + + DEPLOYING: The build task is still running, or the deployments + have started deploying but aren't healthy yet. + RUNNING: All deployments are healthy. + DEPLOY_FAILED: The build task failed or one or more deployments + became unhealthy in the process of deploying + UNHEALTHY: While the application was running, one or more + deployments transition from healthy to unhealthy. + DELETING: Application and its deployments are being deleted. + """ + return self._status + + @property + def deployment_timestamp(self) -> float: + return self._deployment_timestamp + + @property + def target_deployments(self) -> List[str]: + """List of target deployment names in application.""" + if self._target_state.deployment_infos is None: + return [] + return list(self._target_state.deployment_infos.keys()) + + @property + def ingress_deployment(self) -> Optional[str]: + return self._ingress_deployment_name + + @property + def api_type(self) -> APIType: + return self._target_state.api_type + + def recover_target_state_from_checkpoint( + self, checkpoint_data: ApplicationTargetState + ): + logger.info( + f"Recovering target state for application '{self._name}' from checkpoint." + ) + self._set_target_state( + checkpoint_data.deployment_infos, + api_type=checkpoint_data.api_type, + code_version=checkpoint_data.code_version, + target_config=checkpoint_data.config, + target_capacity=checkpoint_data.target_capacity, + target_capacity_direction=checkpoint_data.target_capacity_direction, + deleting=checkpoint_data.deleting, + ) + + # Restore route prefix and docs path from checkpointed deployments when + # the imperatively started application is restarting with controller. + if checkpoint_data.deployment_infos is not None: + self._route_prefix = self._check_routes(checkpoint_data.deployment_infos) + + # Restore app-level autoscaling policy from checkpoint + if ( + checkpoint_data.config + and checkpoint_data.config.autoscaling_policy is not None + ): + self._autoscaling_state_manager.register_application( + self._name, + AutoscalingPolicy( + _serialized_policy_def=checkpoint_data.serialized_application_autoscaling_policy_def, + **checkpoint_data.config.autoscaling_policy, + ), + ) + + def _set_target_state( + self, + deployment_infos: Optional[Dict[str, DeploymentInfo]], + *, + api_type: APIType, + code_version: Optional[str], + target_config: Optional[ServeApplicationSchema], + target_capacity: Optional[float] = None, + target_capacity_direction: Optional[TargetCapacityDirection] = None, + deleting: bool = False, + serialized_application_autoscaling_policy_def: Optional[bytes] = None, + ): + """Set application target state. + + While waiting for build task to finish, this should be + (None, False) + When build task has finished and during normal operation, this should be + (target_deployments, False) + When a request to delete the application has been received, this should be + ({}, True) + """ + if deleting: + self._update_status(ApplicationStatus.DELETING) + else: + self._update_status(ApplicationStatus.DEPLOYING) + + if deployment_infos is None: + self._ingress_deployment_name = None + else: + for name, info in deployment_infos.items(): + if info.ingress: + self._ingress_deployment_name = name + + target_state = ApplicationTargetState( + deployment_infos, + code_version, + target_config, + target_capacity, + target_capacity_direction, + deleting, + api_type=api_type, + serialized_application_autoscaling_policy_def=serialized_application_autoscaling_policy_def, + ) + + self._target_state = target_state + + def _set_target_state_deleting(self): + """Set target state to deleting. + + Wipes the target deployment infos, code version, and config. + """ + self._set_target_state( + deployment_infos={}, + api_type=self._target_state.api_type, + code_version=None, + target_config=None, + deleting=True, + ) + + def _clear_target_state_and_store_config( + self, + target_config: Optional[ServeApplicationSchema], + ): + """Clears the target state and stores the config. + + NOTE: this currently assumes that this method is *only* called when managing + apps deployed with the declarative API. + """ + self._set_target_state( + deployment_infos=None, + api_type=APIType.DECLARATIVE, + code_version=None, + target_config=target_config, + deleting=False, + ) + + def _delete_deployment(self, name: str) -> bool: + """Delete a deployment in the application. + + Args: + name: The name of the deployment to delete. + + Returns: + Whether the target state has changed. + """ + id = DeploymentID(name=name, app_name=self._name) + self._endpoint_state.delete_endpoint(id) + return self._deployment_state_manager.delete_deployment(id) + + def delete(self): + """Delete the application""" + if self._status != ApplicationStatus.DELETING: + logger.info( + f"Deleting app '{self._name}'.", + extra={"log_to_stderr": False}, + ) + self._set_target_state_deleting() + + def is_deleted(self) -> bool: + """Check whether the application is already deleted. + + For an application to be considered deleted, the target state has to be set to + deleting and all deployments have to be deleted. + """ + return self._target_state.deleting and len(self._get_live_deployments()) == 0 + + def should_autoscale(self) -> bool: + """Determine if autoscaling is enabled for the application. + + Returns: + Autoscaling is enabled for the application if any of the deployments have autoscaling enabled. + """ + + return self._autoscaling_state_manager.should_autoscale_application(self._name) + + def autoscale(self) -> bool: + """ + Apply the autoscaling decisions for the application. + If the application has deployment-level autoscaling, it will apply the autoscaling decisions for each deployment. + + Returns: + True if there is a change to number of replicas for any deployment. False otherwise. + """ + target_deployments = self.target_deployments + if len(target_deployments) == 0: + return False + + deployment_to_target_num_replicas: Dict[DeploymentID, int] = {} + for deployment_name in target_deployments: + deployment_id = DeploymentID(name=deployment_name, app_name=self._name) + target_num_replicas = ( + self._deployment_state_manager.get_deployment_target_num_replicas( + deployment_id + ) + ) + if target_num_replicas is None: + continue + deployment_to_target_num_replicas[deployment_id] = target_num_replicas + + if len(deployment_to_target_num_replicas) == 0: + return False + decisions: Dict[ + DeploymentID, int + ] = self._autoscaling_state_manager.get_decision_num_replicas( + self._name, deployment_to_target_num_replicas + ) + + target_state_changed = False + for deployment_id, decision_num_replicas in decisions.items(): + target_state_changed = ( + self._deployment_state_manager.autoscale( + deployment_id, decision_num_replicas + ) + or target_state_changed + ) + return target_state_changed + + def apply_deployment_info( + self, + deployment_name: str, + deployment_info: DeploymentInfo, + ) -> bool: + """Deploys a deployment in the application. + + Args: + deployment_name: The name of the deployment to apply. + deployment_info: The deployment info to apply. + + Returns: + Whether the target state has changed. + """ + route_prefix = deployment_info.route_prefix + if route_prefix is not None and not route_prefix.startswith("/"): + raise RayServeException( + f'Invalid route prefix "{route_prefix}", it must start with "/"' + ) + + deployment_id = DeploymentID(name=deployment_name, app_name=self._name) + + target_state_changed = self._deployment_state_manager.deploy( + deployment_id, deployment_info + ) + + if deployment_info.route_prefix is not None: + config = deployment_info.deployment_config + # Try to get route_patterns from deployment state first (most up-to-date), + # otherwise fall back to existing endpoint patterns + route_patterns = ( + self._deployment_state_manager.get_deployment_route_patterns( + deployment_id + ) + ) + self._endpoint_state.update_endpoint( + deployment_id, + # The current meaning of the "is_cross_language" field is ambiguous. + # We will work on optimizing and removing this field in the future. + # Instead of using the "is_cross_language" field, we will directly + # compare if the replica is Python, which will assist the Python + # router in determining if the replica invocation is a cross-language + # operation. + EndpointInfo( + route=deployment_info.route_prefix, + app_is_cross_language=config.deployment_language + != DeploymentLanguage.PYTHON, + route_patterns=route_patterns, + ), + ) + else: + self._endpoint_state.delete_endpoint(deployment_id) + + return target_state_changed + + def deploy_app(self, deployment_infos: Dict[str, DeploymentInfo]): + """(Re-)deploy the application from list of deployment infos. + + This function should only be called to deploy an app from an + imperative API (i.e., `serve.run` or Java API). + + Raises: RayServeException if there is more than one route prefix + or docs path. + """ + + # Check routes are unique in deployment infos + self._route_prefix = self._check_routes(deployment_infos) + + self._set_target_state( + deployment_infos=deployment_infos, + api_type=APIType.IMPERATIVE, + code_version=None, + target_config=None, + target_capacity=None, + target_capacity_direction=None, + ) + + def apply_app_config( + self, + config: ServeApplicationSchema, + target_capacity: Optional[float], + target_capacity_direction: Optional[TargetCapacityDirection], + deployment_time: float, + ) -> None: + """Apply the config to the application. + + If the code version matches that of the current live deployments + then it only applies the updated config to the deployment state + manager. If the code version doesn't match, this will re-build + the application. + + This function should only be called to (re-)deploy an app from + the declarative API (i.e., through the REST API). + """ + + self._deployment_timestamp = deployment_time + + config_version = get_app_code_version(config) + if config_version == self._target_state.code_version: + try: + overrided_infos = override_deployment_info( + self._target_state.deployment_infos, + config, + ) + self._route_prefix = self._check_routes(overrided_infos) + self._set_target_state( + # Code version doesn't change. + code_version=self._target_state.code_version, + api_type=APIType.DECLARATIVE, + # Everything else must reflect the new config. + deployment_infos=overrided_infos, + target_config=config, + target_capacity=target_capacity, + target_capacity_direction=target_capacity_direction, + ) + except (TypeError, ValueError, RayServeException): + self._clear_target_state_and_store_config(config) + self._update_status( + ApplicationStatus.DEPLOY_FAILED, traceback.format_exc() + ) + except Exception: + self._clear_target_state_and_store_config(config) + self._update_status( + ApplicationStatus.DEPLOY_FAILED, + ( + f"Unexpected error occurred while applying config for " + f"application '{self._name}': \n{traceback.format_exc()}" + ), + ) + else: + # If there is an in progress build task, cancel it. + if self._build_app_task_info and not self._build_app_task_info.finished: + logger.info( + f"Received new config for application '{self._name}'. " + "Cancelling previous request." + ) + ray.cancel(self._build_app_task_info.obj_ref) + + # Halt reconciliation of target deployments. A new target state + # will be set once the new app has finished building. + self._clear_target_state_and_store_config(config) + + # Record telemetry for container runtime env feature + if self._target_state.config.runtime_env.get( + "container" + ) or self._target_state.config.runtime_env.get("image_uri"): + ServeUsageTag.APP_CONTAINER_RUNTIME_ENV_USED.record("1") + + if isinstance(config.autoscaling_policy, dict): + application_autoscaling_policy_function = config.autoscaling_policy.get( + "policy_function" + ) + else: + application_autoscaling_policy_function = None + + deployment_to_autoscaling_policy_function = { + deployment.name: deployment.autoscaling_config.get("policy", {}).get( + "policy_function", DEFAULT_AUTOSCALING_POLICY_NAME + ) + for deployment in config.deployments + if isinstance(deployment.autoscaling_config, dict) + } + deployment_to_request_router_cls = { + deployment.name: deployment.request_router_config.get( + "request_router_class", DEFAULT_REQUEST_ROUTER_PATH + ) + for deployment in config.deployments + if isinstance(deployment.request_router_config, dict) + } + + # Kick off new build app task + logger.info(f"Importing and building app '{self._name}'.") + build_app_obj_ref = build_serve_application.options( + runtime_env=config.runtime_env, + enable_task_events=RAY_SERVE_ENABLE_TASK_EVENTS, + ).remote( + config.import_path, + config_version, + config.name, + config.args, + self._logging_config, + application_autoscaling_policy_function, + deployment_to_autoscaling_policy_function, + deployment_to_request_router_cls, + ) + self._build_app_task_info = BuildAppTaskInfo( + obj_ref=build_app_obj_ref, + code_version=config_version, + config=config, + target_capacity=target_capacity, + target_capacity_direction=target_capacity_direction, + finished=False, + ) + + def _get_live_deployments(self) -> List[str]: + return self._deployment_state_manager.get_deployments_in_application(self._name) + + def _determine_app_status(self) -> Tuple[ApplicationStatus, str]: + """Check deployment statuses and target state, and determine the + corresponding application status. + + Returns: + Status (ApplicationStatus): + RUNNING: all deployments are healthy or autoscaling. + DEPLOYING: there is one or more updating deployments, + and there are no unhealthy deployments. + DEPLOY_FAILED: one or more deployments became unhealthy + while the application was deploying. + UNHEALTHY: one or more deployments became unhealthy + while the application was running. + DELETING: the application is being deleted. + Error message (str): + Non-empty string if status is DEPLOY_FAILED or UNHEALTHY + """ + + if self._target_state.deleting: + return ApplicationStatus.DELETING, "" + + # Get the lowest rank, i.e. highest priority, deployment status info object + # The deployment status info with highest priority determines the corresponding + # application status to set. + deployment_statuses = self.get_deployments_statuses() + lowest_rank_status = min(deployment_statuses, key=lambda info: info.rank) + if lowest_rank_status.status == DeploymentStatus.DEPLOY_FAILED: + failed_deployments = [ + s.name + for s in deployment_statuses + if s.status == DeploymentStatus.DEPLOY_FAILED + ] + return ( + ApplicationStatus.DEPLOY_FAILED, + f"Failed to update the deployments {failed_deployments}.", + ) + elif lowest_rank_status.status == DeploymentStatus.UNHEALTHY: + unhealthy_deployment_names = [ + s.name + for s in deployment_statuses + if s.status == DeploymentStatus.UNHEALTHY + ] + return ( + ApplicationStatus.UNHEALTHY, + f"The deployments {unhealthy_deployment_names} are UNHEALTHY.", + ) + elif lowest_rank_status.status == DeploymentStatus.UPDATING: + return ApplicationStatus.DEPLOYING, "" + elif ( + lowest_rank_status.status + in [DeploymentStatus.UPSCALING, DeploymentStatus.DOWNSCALING] + and lowest_rank_status.status_trigger + == DeploymentStatusTrigger.CONFIG_UPDATE_STARTED + ): + return ApplicationStatus.DEPLOYING, "" + else: + return ApplicationStatus.RUNNING, "" + + def _reconcile_build_app_task( + self, + ) -> Tuple[Optional[bytes], Optional[Dict], BuildAppStatus, str]: + """If necessary, reconcile the in-progress build task. + + Returns: + Serialized application autoscaling policy def (bytes): + The serialized application autoscaling policy def returned from the build app task + if it was built successfully, otherwise None. + Deploy arguments (Dict[str, DeploymentInfo]): + The deploy arguments returned from the build app task + and their code version. + Status (BuildAppStatus): + NO_TASK_IN_PROGRESS: There is no build task to reconcile. + SUCCEEDED: Task finished successfully. + FAILED: An error occurred during execution of build app task + IN_PROGRESS: Task hasn't finished yet. + Error message (str): + Non-empty string if status is DEPLOY_FAILED or UNHEALTHY + """ + if self._build_app_task_info is None or self._build_app_task_info.finished: + return None, None, BuildAppStatus.NO_TASK_IN_PROGRESS, "" + + if not check_obj_ref_ready_nowait(self._build_app_task_info.obj_ref): + return None, None, BuildAppStatus.IN_PROGRESS, "" + + # Retrieve build app task result + self._build_app_task_info.finished = True + try: + serialized_application_autoscaling_policy_def, args, err = ray.get( + self._build_app_task_info.obj_ref + ) + if err is None: + logger.info(f"Imported and built app '{self._name}' successfully.") + else: + return ( + None, + None, + BuildAppStatus.FAILED, + f"Deploying app '{self._name}' failed with exception:\n{err}", + ) + except RuntimeEnvSetupError: + error_msg = ( + f"Runtime env setup for app '{self._name}' failed:\n" + + traceback.format_exc() + ) + return None, None, BuildAppStatus.FAILED, error_msg + except Exception: + error_msg = ( + f"Unexpected error occurred while deploying application " + f"'{self._name}': \n{traceback.format_exc()}" + ) + return None, None, BuildAppStatus.FAILED, error_msg + + # Convert serialized deployment args (returned by build app task) + # to deployment infos and apply option overrides from config + try: + deployment_infos = { + params["deployment_name"]: deploy_args_to_deployment_info( + **params, app_name=self._name + ) + for params in args + } + deployment_to_serialized_autoscaling_policy_def = { + params["deployment_name"]: params["serialized_autoscaling_policy_def"] + for params in args + if params["serialized_autoscaling_policy_def"] is not None + } + deployment_to_serialized_request_router_cls = { + params["deployment_name"]: params["serialized_request_router_cls"] + for params in args + if params["serialized_request_router_cls"] is not None + } + overrided_infos = override_deployment_info( + deployment_infos, + self._build_app_task_info.config, + deployment_to_serialized_autoscaling_policy_def, + deployment_to_serialized_request_router_cls, + ) + self._route_prefix = self._check_routes(overrided_infos) + return ( + serialized_application_autoscaling_policy_def, + overrided_infos, + BuildAppStatus.SUCCEEDED, + "", + ) + except (TypeError, ValueError, RayServeException): + return None, None, BuildAppStatus.FAILED, traceback.format_exc() + except Exception: + error_msg = ( + f"Unexpected error occurred while applying config for application " + f"'{self._name}': \n{traceback.format_exc()}" + ) + return None, None, BuildAppStatus.FAILED, error_msg + + def _check_routes( + self, deployment_infos: Dict[str, DeploymentInfo] + ) -> Tuple[str, str]: + """Check route prefixes of deployments in app. + + There should only be one non-null route prefix. If there is one, + set it as the application route prefix. This function must be + run every control loop iteration because the target config could + be updated without kicking off a new task. + + Returns: route prefix. + Raises: RayServeException if more than one route prefix is found among deployments. + """ + num_route_prefixes = 0 + route_prefix = None + for info in deployment_infos.values(): + # Update route prefix of application, which may be updated + # through a redeployed config. + if info.route_prefix is not None: + route_prefix = info.route_prefix + num_route_prefixes += 1 + + if num_route_prefixes > 1: + raise RayServeException( + f'Found multiple route prefixes from application "{self._name}",' + " Please specify only one route prefix for the application " + "to avoid this issue." + ) + + return route_prefix + + def _reconcile_target_deployments(self) -> None: + """Reconcile target deployments in application target state. + + Ensure each deployment is running on up-to-date info, and + remove outdated deployments from the application. + """ + target_state_changed = False + + # Set target state for each deployment + for deployment_name, info in self._target_state.deployment_infos.items(): + deploy_info = deepcopy(info) + + # Apply the target capacity information to the deployment info. + deploy_info.set_target_capacity( + new_target_capacity=self._target_state.target_capacity, + new_target_capacity_direction=( + self._target_state.target_capacity_direction + ), + ) + + # Apply the application logging config to the deployment logging config + # if it is not set. + if ( + self._target_state.config + and self._target_state.config.logging_config + and deploy_info.deployment_config.logging_config is None + ): + deploy_info.deployment_config.logging_config = ( + self._target_state.config.logging_config + ) + target_state_changed = ( + self.apply_deployment_info(deployment_name, deploy_info) + or target_state_changed + ) + + # Delete outdated deployments + for deployment_name in self._get_live_deployments(): + if deployment_name not in self.target_deployments: + target_state_changed = ( + self._delete_deployment(deployment_name) or target_state_changed + ) + + return target_state_changed + + def update(self) -> Tuple[bool, bool]: + """Attempts to reconcile this application to match its target state. + + Updates the application status and status message based on the + current state of the system. + + Returns: + Whether the target state has changed. + """ + + target_state_changed = False + # If the application is being deleted, ignore any build task results to + # avoid flipping the state back to DEPLOYING/RUNNING. + if not self._target_state.deleting: + ( + serialized_application_autoscaling_policy_def, + infos, + task_status, + msg, + ) = self._reconcile_build_app_task() + if task_status == BuildAppStatus.SUCCEEDED: + target_state_changed = True + self._set_target_state( + deployment_infos=infos, + code_version=self._build_app_task_info.code_version, + api_type=self._target_state.api_type, + target_config=self._build_app_task_info.config, + target_capacity=self._build_app_task_info.target_capacity, + target_capacity_direction=( + self._build_app_task_info.target_capacity_direction + ), + serialized_application_autoscaling_policy_def=serialized_application_autoscaling_policy_def, + ) + # Handling the case where the user turns off/turns on app-level autoscaling policy, + # between app deployment. + if ( + self._target_state.config is not None + and self._target_state.config.autoscaling_policy is not None + ): + self._autoscaling_state_manager.register_application( + self._name, + AutoscalingPolicy( + _serialized_policy_def=serialized_application_autoscaling_policy_def, + **self._target_state.config.autoscaling_policy, + ), + ) + else: + self._autoscaling_state_manager.deregister_application(self._name) + elif task_status == BuildAppStatus.FAILED: + self._update_status(ApplicationStatus.DEPLOY_FAILED, msg) + + # Only reconcile deployments when the build app task is finished. If + # it's not finished, we don't know what the target list of deployments + # is, so we don't perform any reconciliation. + if self._target_state.deployment_infos is not None: + target_state_changed = ( + self._reconcile_target_deployments() or target_state_changed + ) + status, status_msg = self._determine_app_status() + self._update_status(status, status_msg) + + # Check if app is ready to be deleted + if self._target_state.deleting: + return self.is_deleted(), target_state_changed + return False, target_state_changed + + def get_checkpoint_data(self) -> ApplicationTargetState: + return self._target_state + + def get_deployments_statuses(self) -> List[DeploymentStatusInfo]: + """Return all deployment status information""" + deployments = [ + DeploymentID(name=deployment, app_name=self._name) + for deployment in self.target_deployments + ] + return self._deployment_state_manager.get_deployment_statuses(deployments) + + def get_application_status_info(self) -> ApplicationStatusInfo: + """Return the application status information""" + return ApplicationStatusInfo( + self._status, + message=self._status_msg, + deployment_timestamp=self._deployment_timestamp, + ) + + def list_deployment_details(self) -> Dict[str, DeploymentDetails]: + """Gets detailed info on all live deployments in this application. + (Does not include deleted deployments.) + + Returns: + A dictionary of deployment infos. The set of deployment info returned + may not be the full list of deployments that are part of the application. + This can happen when the application is still deploying and bringing up + deployments, or when the application is deleting and some deployments have + been deleted. + """ + details = { + deployment_name: self._deployment_state_manager.get_deployment_details( + DeploymentID(name=deployment_name, app_name=self._name) + ) + for deployment_name in self.target_deployments + } + return {k: v for k, v in details.items() if v is not None} + + def _update_status(self, status: ApplicationStatus, status_msg: str = "") -> None: + if ( + status_msg + and status + in [ + ApplicationStatus.DEPLOY_FAILED, + ApplicationStatus.UNHEALTHY, + ] + and status_msg != self._status_msg + ): + logger.error(status_msg) + + self._status = status + self._status_msg = status_msg + + +class ApplicationStateManager: + def __init__( + self, + deployment_state_manager: DeploymentStateManager, + autoscaling_state_manager: AutoscalingStateManager, + endpoint_state: EndpointState, + kv_store: KVStoreBase, + logging_config: LoggingConfig, + ): + self._deployment_state_manager = deployment_state_manager + self._autoscaling_state_manager = autoscaling_state_manager + self._endpoint_state = endpoint_state + self._kv_store = kv_store + self._logging_config = logging_config + + self._shutting_down = False + + self._application_states: Dict[str, ApplicationState] = {} + self._recover_from_checkpoint() + + def _recover_from_checkpoint(self): + checkpoint = self._kv_store.get(CHECKPOINT_KEY) + if checkpoint is not None: + application_state_info = cloudpickle.loads(checkpoint) + + for app_name, checkpoint_data in application_state_info.items(): + app_state = ApplicationState( + app_name, + self._deployment_state_manager, + self._autoscaling_state_manager, + self._endpoint_state, + self._logging_config, + ) + app_state.recover_target_state_from_checkpoint(checkpoint_data) + self._application_states[app_name] = app_state + + def delete_app(self, name: str) -> None: + """Delete application by name""" + if name not in self._application_states: + return + self._application_states[name].delete() + + def deploy_apps(self, name_to_deployment_args: Dict[str, List[Dict]]) -> None: + live_route_prefixes: Dict[str, str] = { + app_state.route_prefix: app_name + for app_name, app_state in self._application_states.items() + if app_state.route_prefix is not None + and not app_state.status == ApplicationStatus.DELETING + } + + for name, deployment_args in name_to_deployment_args.items(): + for deploy_param in deployment_args: + # Make sure route_prefix is not being used by other application. + deploy_app_prefix = deploy_param.get("route_prefix") + if deploy_app_prefix is None: + continue + + existing_app_name = live_route_prefixes.get(deploy_app_prefix) + # It's ok to redeploy an app with the same prefix + # if it has the same name as the app already using that prefix. + if existing_app_name is not None and existing_app_name != name: + raise RayServeException( + f"Prefix {deploy_app_prefix} is being used by application " + f'"{existing_app_name}". Failed to deploy application "{name}".' + ) + + # We might be deploying more than one app, + # so we need to add this app's prefix to the + # set of live route prefixes that we're checking + # against during this batch operation. + live_route_prefixes[deploy_app_prefix] = name + + if name not in self._application_states: + self._application_states[name] = ApplicationState( + name, + self._deployment_state_manager, + self._autoscaling_state_manager, + self._endpoint_state, + self._logging_config, + ) + ServeUsageTag.NUM_APPS.record(str(len(self._application_states))) + + deployment_infos = { + params["deployment_name"]: deploy_args_to_deployment_info( + **params, app_name=name + ) + for params in deployment_args + } + self._application_states[name].deploy_app(deployment_infos) + + def deploy_app(self, name: str, deployment_args: List[Dict]) -> None: + """Deploy the specified app to the list of deployment arguments. + + This function should only be called if the app is being deployed + through serve.run instead of from a config. + + Args: + name: application name + deployment_args_list: arguments for deploying a list of deployments. + + Raises: + RayServeException: If the list of deployments is trying to + use a route prefix that is already used by another application + """ + self.deploy_apps({name: deployment_args}) + + def apply_app_configs( + self, + app_configs: List[ServeApplicationSchema], + *, + deployment_time: float = 0, + target_capacity: Optional[float] = None, + target_capacity_direction: Optional[TargetCapacityDirection] = None, + ): + """Declaratively apply the list of application configs. + + The applications will be reconciled to match the target state of the config. + + Any applications previously deployed declaratively that are *not* present in + the list will be deleted. + """ + for app_config in app_configs: + if app_config.name not in self._application_states: + logger.info(f"Deploying new app '{app_config.name}'.") + self._application_states[app_config.name] = ApplicationState( + app_config.name, + self._deployment_state_manager, + self._autoscaling_state_manager, + endpoint_state=self._endpoint_state, + logging_config=self._logging_config, + ) + + self._application_states[app_config.name].apply_app_config( + app_config, + target_capacity, + target_capacity_direction, + deployment_time=deployment_time, + ) + + # Delete all apps that were previously deployed via the declarative API + # but are not in the config being applied. + existing_apps = { + name + for name, app_state in self._application_states.items() + if app_state.api_type == APIType.DECLARATIVE + } + apps_in_config = {app_config.name for app_config in app_configs} + for app_to_delete in existing_apps - apps_in_config: + self.delete_app(app_to_delete) + + ServeUsageTag.NUM_APPS.record(str(len(self._application_states))) + + def get_deployments(self, app_name: str) -> List[str]: + """Return all deployment names by app name""" + if app_name not in self._application_states: + return [] + return self._application_states[app_name].target_deployments + + def get_deployments_statuses(self, app_name: str) -> List[DeploymentStatusInfo]: + """Return all deployment statuses by app name""" + if app_name not in self._application_states: + return [] + return self._application_states[app_name].get_deployments_statuses() + + def get_app_status(self, name: str) -> ApplicationStatus: + if name not in self._application_states: + return ApplicationStatus.NOT_STARTED + + return self._application_states[name].status + + def get_app_status_info(self, name: str) -> ApplicationStatusInfo: + if name not in self._application_states: + return ApplicationStatusInfo( + ApplicationStatus.NOT_STARTED, + message=f"Application {name} doesn't exist", + deployment_timestamp=0, + ) + return self._application_states[name].get_application_status_info() + + def get_docs_path(self, app_name: str) -> Optional[str]: + return self._application_states[app_name].docs_path + + def get_route_prefix(self, name: str) -> Optional[str]: + return self._application_states[name].route_prefix + + def get_ingress_deployment_name(self, name: str) -> Optional[str]: + if name not in self._application_states: + return None + + return self._application_states[name].ingress_deployment + + def get_app_source(self, name: str) -> APIType: + return self._application_states[name].api_type + + def list_app_statuses( + self, source: Optional[APIType] = None + ) -> Dict[str, ApplicationStatusInfo]: + """Return a dictionary with {app name: application info} + + Args: + source: Optional API type filter. If provided, only returns apps + deployed via the specified API type. + + Returns: + Dict[str, ApplicationStatusInfo]: A dictionary mapping application names + to their corresponding status information. + """ + if source is None: + return { + name: self._application_states[name].get_application_status_info() + for name in self._application_states + } + else: + return { + name: self._application_states[name].get_application_status_info() + for name in self._application_states + if self.get_app_source(name) is source + } + + def list_deployment_details(self, name: str) -> Dict[str, DeploymentDetails]: + """Gets detailed info on all deployments in specified application.""" + if name not in self._application_states: + return {} + return self._application_states[name].list_deployment_details() + + def update(self): + """Update each application state.""" + apps_to_be_deleted = [] + any_target_state_changed = False + for name, app in self._application_states.items(): + if app.should_autoscale(): + any_target_state_changed = app.autoscale() or any_target_state_changed + ready_to_be_deleted, app_target_state_changed = app.update() + any_target_state_changed = ( + any_target_state_changed or app_target_state_changed + ) + if ready_to_be_deleted: + apps_to_be_deleted.append(name) + logger.debug(f"Application '{name}' deleted successfully.") + + if len(apps_to_be_deleted) > 0: + for app_name in apps_to_be_deleted: + self._autoscaling_state_manager.deregister_application(app_name) + del self._application_states[app_name] + ServeUsageTag.NUM_APPS.record(str(len(self._application_states))) + + if any_target_state_changed: + self.save_checkpoint() + self._deployment_state_manager.save_checkpoint() + + def shutdown(self) -> None: + self._shutting_down = True + + for app_state in self._application_states.values(): + app_state.delete() + + self._kv_store.delete(CHECKPOINT_KEY) + + def is_ready_for_shutdown(self) -> bool: + """Return whether all applications have shut down. + + Iterate through all application states and check if all their applications + are deleted. + """ + return self._shutting_down and all( + app_state.is_deleted() for app_state in self._application_states.values() + ) + + def save_checkpoint(self) -> None: + """Write a checkpoint of all application states.""" + if self._shutting_down: + # Once we're told to shut down, stop writing checkpoints. + # Calling .shutdown() deletes any existing checkpoint. + return + + application_state_info = { + app_name: app_state.get_checkpoint_data() + for app_name, app_state in self._application_states.items() + } + + self._kv_store.put( + CHECKPOINT_KEY, + cloudpickle.dumps(application_state_info), + ) + + +@ray.remote(num_cpus=0, max_calls=1) +def build_serve_application( + import_path: str, + code_version: str, + name: str, + args: Dict, + logging_config: LoggingConfig, + application_autoscaling_policy_function: Optional[str], + deployment_to_autoscaling_policy_function: Dict[str, str], + deployment_to_request_router_cls: Dict[str, str], +) -> Tuple[Optional[bytes], Optional[List[Dict]], Optional[str]]: + """Import and build a Serve application. + + Args: + import_path: import path to top-level bound deployment. + code_version: code version inferred from app config. All + deployment versions are set to this code version. + name: application name. If specified, application will be deployed + without removing existing applications. + args: Arguments to be passed to the application builder. + logging_config: the logging config for the build app task. + application_autoscaling_policy_function: the application autoscaling policy function name + deployment_to_autoscaling_policy_function: a dictionary mapping deployment names to autoscaling policy function names + deployment_to_request_router_cls: a dictionary mapping deployment names to request router class names + + Returns: + Serialized application autoscaling policy def: a serialized autoscaling + policy def for the application if it was built successfully, otherwise None. + Deploy arguments: a list of deployment arguments if application + was built successfully, otherwise None. + Error message: a string if an error was raised, otherwise None. + """ + configure_component_logger( + component_name="controller", + component_id=f"build_{name}_{os.getpid()}", + logging_config=logging_config, + ) + + try: + from ray.serve._private.api import call_user_app_builder_with_args_if_necessary + + # Import and build the application. + args_info_str = f" with arguments {args}" if args else "" + logger.info(f"Importing application '{name}'{args_info_str}.") + + app = call_user_app_builder_with_args_if_necessary( + import_attr(import_path), args + ) + + deploy_args_list = [] + built_app: BuiltApplication = build_app( + app, + name=name, + default_runtime_env=ray.get_runtime_context().runtime_env, + ) + num_ingress_deployments = 0 + + def _get_serialized_def(attr_path: str) -> bytes: + module, attr = import_module_and_attr(attr_path) + cloudpickle.register_pickle_by_value(module) + serialized = cloudpickle.dumps(attr) + cloudpickle.unregister_pickle_by_value(module) + return serialized + + application_serialized_autoscaling_policy_def = None + if application_autoscaling_policy_function is not None: + application_serialized_autoscaling_policy_def = _get_serialized_def( + application_autoscaling_policy_function + ) + for deployment in built_app.deployments: + if inspect.isclass(deployment.func_or_class) and issubclass( + deployment.func_or_class, ASGIAppReplicaWrapper + ): + num_ingress_deployments += 1 + is_ingress = deployment.name == built_app.ingress_deployment_name + deployment_to_serialized_autoscaling_policy_def = None + deployment_to_serialized_request_router_cls = None + if deployment.name in deployment_to_autoscaling_policy_function: + deployment_to_serialized_autoscaling_policy_def = _get_serialized_def( + deployment_to_autoscaling_policy_function[deployment.name] + ) + if deployment.name in deployment_to_request_router_cls: + deployment_to_serialized_request_router_cls = _get_serialized_def( + deployment_to_request_router_cls[deployment.name] + ) + deploy_args_list.append( + get_deploy_args( + name=deployment._name, + replica_config=deployment._replica_config, + ingress=is_ingress, + deployment_config=deployment._deployment_config, + version=code_version, + route_prefix="/" if is_ingress else None, + serialized_autoscaling_policy_def=deployment_to_serialized_autoscaling_policy_def, + serialized_request_router_cls=deployment_to_serialized_request_router_cls, + ) + ) + if num_ingress_deployments > 1: + return ( + None, + None, + ( + f'Found multiple FastAPI deployments in application "{built_app.name}". ' + "Please only include one deployment with @serve.ingress " + "in your application to avoid this issue." + ), + ) + return application_serialized_autoscaling_policy_def, deploy_args_list, None + except KeyboardInterrupt: + # Error is raised when this task is canceled with ray.cancel(), which + # happens when deploy_apps() is called. + logger.info( + "Existing config deployment request terminated because of keyboard " + "interrupt." + ) + return None, None, None + except Exception: + logger.error( + f"Exception importing application '{name}'.\n{traceback.format_exc()}" + ) + return None, None, traceback.format_exc() + + +def override_deployment_info( + deployment_infos: Dict[str, DeploymentInfo], + override_config: Optional[ServeApplicationSchema], + deployment_to_serialized_autoscaling_policy_def: Optional[Dict[str, bytes]] = None, + deployment_to_serialized_request_router_cls: Optional[Dict[str, bytes]] = None, +) -> Dict[str, DeploymentInfo]: + """Override deployment infos with options from app config. + + Args: + app_name: application name + deployment_infos: deployment info loaded from code + override_config: application config deployed by user with + options to override those loaded from code. + deployment_to_serialized_autoscaling_policy_def: serialized autoscaling policy def for each deployment + deployment_to_serialized_request_router_cls: serialized request router cls for each deployment + + Returns: the updated deployment infos. + + Raises: + ValueError: If config options have invalid values. + TypeError: If config options have invalid types. + """ + + deployment_infos = deepcopy(deployment_infos) + if override_config is None: + return deployment_infos + + config_dict = override_config.dict(exclude_unset=True) + deployment_override_options = config_dict.get("deployments", []) + + # Override options for each deployment listed in the config. + for options in deployment_override_options: + if "max_ongoing_requests" in options: + options["max_ongoing_requests"] = options.get("max_ongoing_requests") + + deployment_name = options["name"] + if deployment_name not in deployment_infos: + raise ValueError( + f"Deployment '{deployment_name}' does not exist. " + f"Available: {list(deployment_infos.keys())}" + ) + + info = deployment_infos[deployment_name] + original_options = info.deployment_config.dict() + original_options["user_configured_option_names"].update(set(options)) + + # Override `max_ongoing_requests` and `autoscaling_config` if + # `num_replicas="auto"` + if options.get("num_replicas") == "auto": + options["num_replicas"] = None + + new_config = AutoscalingConfig.default().dict() + # If `autoscaling_config` is specified, its values override + # the default `num_replicas="auto"` configuration + autoscaling_config = ( + options.get("autoscaling_config") + or info.deployment_config.autoscaling_config + ) + if autoscaling_config: + new_config.update(autoscaling_config) + + if ( + deployment_to_serialized_autoscaling_policy_def + and deployment_name in deployment_to_serialized_autoscaling_policy_def + ): + # By setting the serialized policy def, AutoscalingConfig constructor will not + # try to import the policy from the string import path + policy_obj = AutoscalingPolicy.from_serialized_policy_def( + new_config["policy"], + deployment_to_serialized_autoscaling_policy_def[deployment_name], + ) + new_config["policy"] = policy_obj + options["autoscaling_config"] = AutoscalingConfig(**new_config) + + ServeUsageTag.AUTO_NUM_REPLICAS_USED.record("1") + + # What to pass to info.update + override_options = {} + + # Merge app-level and deployment-level runtime_envs. + replica_config = info.replica_config + app_runtime_env = override_config.runtime_env + if "ray_actor_options" in options: + # If specified, get ray_actor_options from config + override_actor_options = options.pop("ray_actor_options", {}) + else: + # Otherwise, get options from application code (and default to {} + # if the code sets options to None). + override_actor_options = replica_config.ray_actor_options or {} + + override_placement_group_bundles = options.pop( + "placement_group_bundles", replica_config.placement_group_bundles + ) + override_placement_group_strategy = options.pop( + "placement_group_strategy", replica_config.placement_group_strategy + ) + + override_max_replicas_per_node = options.pop( + "max_replicas_per_node", replica_config.max_replicas_per_node + ) + + # Record telemetry for container runtime env feature at deployment level + if override_actor_options.get("runtime_env") and ( + override_actor_options["runtime_env"].get("container") + or override_actor_options["runtime_env"].get("image_uri") + ): + ServeUsageTag.DEPLOYMENT_CONTAINER_RUNTIME_ENV_USED.record("1") + + merged_env = override_runtime_envs_except_env_vars( + app_runtime_env, override_actor_options.get("runtime_env", {}) + ) + override_actor_options.update({"runtime_env": merged_env}) + + replica_config.update( + ray_actor_options=override_actor_options, + placement_group_bundles=override_placement_group_bundles, + placement_group_strategy=override_placement_group_strategy, + max_replicas_per_node=override_max_replicas_per_node, + ) + override_options["replica_config"] = replica_config + + if "request_router_config" in options: + request_router_config = options.get("request_router_config") + if request_router_config: + if ( + deployment_to_serialized_request_router_cls + and deployment_name in deployment_to_serialized_request_router_cls + ): + # By setting the serialized request router cls, RequestRouterConfig constructor will not + # try to import the request router cls from the string import path + options[ + "request_router_config" + ] = RequestRouterConfig.from_serialized_request_router_cls( + request_router_config, + deployment_to_serialized_request_router_cls[deployment_name], + ) + else: + options["request_router_config"] = RequestRouterConfig( + **request_router_config + ) + + # Override deployment config options + options.pop("name", None) + original_options.update(options) + override_options["deployment_config"] = DeploymentConfig(**original_options) + deployment_infos[deployment_name] = info.update(**override_options) + + deployment_config = deployment_infos[deployment_name].deployment_config + if ( + deployment_config.autoscaling_config is not None + and deployment_config.max_ongoing_requests + < deployment_config.autoscaling_config.get_target_ongoing_requests() + ): + logger.warning( + "Autoscaling will never happen, " + "because 'max_ongoing_requests' is less than " + "'target_ongoing_requests' now." + ) + + # Overwrite ingress route prefix + app_route_prefix = config_dict.get("route_prefix", DEFAULT.VALUE) + validate_route_prefix(app_route_prefix) + for deployment in list(deployment_infos.values()): + if ( + app_route_prefix is not DEFAULT.VALUE + and deployment.route_prefix is not None + ): + deployment.route_prefix = app_route_prefix + + return deployment_infos diff --git a/lib/python3.12/site-packages/ray/serve/_private/autoscaling_state.py b/lib/python3.12/site-packages/ray/serve/_private/autoscaling_state.py new file mode 100644 index 0000000000000000000000000000000000000000..8f3a5935440430cf53709cdcd45b4e2090e7b5a4 --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/_private/autoscaling_state.py @@ -0,0 +1,1010 @@ +import logging +import time +from collections import defaultdict +from typing import Any, Callable, Dict, List, Optional, Set, Tuple + +from ray.serve._private.common import ( + RUNNING_REQUESTS_KEY, + ApplicationName, + DeploymentID, + HandleMetricReport, + ReplicaID, + ReplicaMetricReport, + TargetCapacityDirection, + TimeSeries, +) +from ray.serve._private.constants import ( + RAY_SERVE_AGGREGATE_METRICS_AT_CONTROLLER, + RAY_SERVE_MIN_HANDLE_METRICS_TIMEOUT_S, + SERVE_LOGGER_NAME, +) +from ray.serve._private.deployment_info import DeploymentInfo +from ray.serve._private.metrics_utils import ( + aggregate_timeseries, + merge_instantaneous_total, +) +from ray.serve._private.usage import ServeUsageTag +from ray.serve._private.utils import get_capacity_adjusted_num_replicas +from ray.serve.config import AutoscalingContext, AutoscalingPolicy + +logger = logging.getLogger(SERVE_LOGGER_NAME) + + +class DeploymentAutoscalingState: + """Manages autoscaling for a single deployment.""" + + def __init__(self, deployment_id: DeploymentID): + self._deployment_id = deployment_id + + # Map from handle ID to handle request metric report. Metrics + # are removed from this dict either when the actor on which the + # handle lived dies, or after a period of no updates. + self._handle_requests: Dict[str, HandleMetricReport] = dict() + # Map from replica ID to replica request metric report. Metrics + # are removed from this dict when a replica is stopped. + # Prometheus + Custom metrics from each replica are also included + self._replica_metrics: Dict[ReplicaID, ReplicaMetricReport] = dict() + + self._deployment_info = None + self._config = None + self._policy: Optional[ + Callable[[AutoscalingContext], Tuple[int, Optional[Dict[str, Any]]]] + ] = None + # user defined policy returns a dictionary of state that is persisted between autoscaling decisions + # content of the dictionary is determined by the user defined policy + self._policy_state: Optional[Dict[str, Any]] = None + self._running_replicas: List[ReplicaID] = [] + self._target_capacity: Optional[float] = None + self._target_capacity_direction: Optional[TargetCapacityDirection] = None + + def register(self, info: DeploymentInfo, curr_target_num_replicas: int) -> int: + """Registers an autoscaling deployment's info. + + Returns the number of replicas the target should be set to. + """ + + config = info.deployment_config.autoscaling_config + if config is None: + raise ValueError( + f"Autoscaling config is not set for deployment {self._deployment_id}" + ) + if ( + self._deployment_info is None or self._deployment_info.config_changed(info) + ) and config.initial_replicas is not None: + target_num_replicas = config.initial_replicas + else: + target_num_replicas = curr_target_num_replicas + + self._deployment_info = info + self._config = config + self._policy = self._config.policy.get_policy() + self._target_capacity = info.target_capacity + self._target_capacity_direction = info.target_capacity_direction + self._policy_state = {} + + # Log when custom autoscaling policy is used for deployment + if not self._config.policy.is_default_policy_function(): + logger.info( + f"Using custom autoscaling policy '{self._config.policy.policy_function}' " + f"for deployment '{self._deployment_id}'." + ) + # Record telemetry for custom autoscaling policy usage + ServeUsageTag.CUSTOM_AUTOSCALING_POLICY_USED.record("1") + + return self.apply_bounds(target_num_replicas) + + def on_replica_stopped(self, replica_id: ReplicaID): + if replica_id in self._replica_metrics: + del self._replica_metrics[replica_id] + + def get_num_replicas_lower_bound(self) -> int: + if self._config.initial_replicas is not None and ( + self._target_capacity_direction == TargetCapacityDirection.UP + ): + return get_capacity_adjusted_num_replicas( + self._config.initial_replicas, + self._target_capacity, + ) + else: + return get_capacity_adjusted_num_replicas( + self._config.min_replicas, + self._target_capacity, + ) + + def get_num_replicas_upper_bound(self) -> int: + return get_capacity_adjusted_num_replicas( + self._config.max_replicas, + self._target_capacity, + ) + + def update_running_replica_ids(self, running_replicas: List[ReplicaID]): + """Update cached set of running replica IDs for this deployment.""" + self._running_replicas = running_replicas + + def is_within_bounds(self, num_replicas_running_at_target_version: int): + """Whether or not this deployment is within the autoscaling bounds. + + Returns: True if the number of running replicas for the current + deployment version is within the autoscaling bounds. False + otherwise. + """ + + return ( + self.apply_bounds(num_replicas_running_at_target_version) + == num_replicas_running_at_target_version + ) + + def apply_bounds(self, num_replicas: int) -> int: + """Clips a replica count with current autoscaling bounds. + + This takes into account target capacity. + """ + + return max( + self.get_num_replicas_lower_bound(), + min(self.get_num_replicas_upper_bound(), num_replicas), + ) + + def record_request_metrics_for_replica( + self, replica_metric_report: ReplicaMetricReport + ) -> None: + """Records average number of ongoing requests at a replica.""" + replica_id = replica_metric_report.replica_id + send_timestamp = replica_metric_report.timestamp + + if ( + replica_id not in self._replica_metrics + or send_timestamp > self._replica_metrics[replica_id].timestamp + ): + self._replica_metrics[replica_id] = replica_metric_report + + def record_request_metrics_for_handle( + self, + handle_metric_report: HandleMetricReport, + ) -> None: + """Records average number of queued and running requests at a handle for this + deployment. + """ + handle_id = handle_metric_report.handle_id + send_timestamp = handle_metric_report.timestamp + if ( + handle_id not in self._handle_requests + or send_timestamp > self._handle_requests[handle_id].timestamp + ): + self._handle_requests[handle_id] = handle_metric_report + + def drop_stale_handle_metrics(self, alive_serve_actor_ids: Set[str]) -> None: + """Drops handle metrics that are no longer valid. + + This includes handles that live on Serve Proxy or replica actors + that have died AND handles from which the controller hasn't + received an update for too long. + """ + + timeout_s = max( + 2 * self._config.metrics_interval_s, + RAY_SERVE_MIN_HANDLE_METRICS_TIMEOUT_S, + ) + for handle_id, handle_metric in list(self._handle_requests.items()): + # Drop metrics for handles that are on Serve proxy/replica + # actors that have died + if ( + handle_metric.is_serve_component_source + and handle_metric.actor_id is not None + and handle_metric.actor_id not in alive_serve_actor_ids + ): + del self._handle_requests[handle_id] + if handle_metric.total_requests > 0: + logger.debug( + f"Dropping metrics for handle '{handle_id}' because the Serve " + f"actor it was on ({handle_metric.actor_id}) is no longer " + f"alive. It had {handle_metric.total_requests} ongoing requests" + ) + # Drop metrics for handles that haven't sent an update in a while. + # This is expected behavior for handles that were on replicas or + # proxies that have been shut down. + elif time.time() - handle_metric.timestamp >= timeout_s: + del self._handle_requests[handle_id] + if handle_metric.total_requests > 0: + actor_id = handle_metric.actor_id + actor_info = f"on actor '{actor_id}' " if actor_id else "" + logger.info( + f"Dropping stale metrics for handle '{handle_id}' {actor_info}" + f"because no update was received for {timeout_s:.1f}s. " + f"Ongoing requests was: {handle_metric.total_requests}." + ) + + def get_decision_num_replicas( + self, curr_target_num_replicas: int, _skip_bound_check: bool = False + ) -> int: + """Decide the target number of replicas to autoscale to. + + The decision is based off of the number of requests received + for this deployment. After the decision number of replicas is + returned by the policy, it is then bounded by the bounds min + and max adjusted by the target capacity and returned. If + `_skip_bound_check` is True, then the bounds are not applied. + """ + if self._policy is None: + raise ValueError(f"Policy is not set for deployment {self._deployment_id}.") + autoscaling_context = self.get_autoscaling_context(curr_target_num_replicas) + decision_num_replicas, self._policy_state = self._policy(autoscaling_context) + if _skip_bound_check: + return decision_num_replicas + + return self.apply_bounds(decision_num_replicas) + + def get_autoscaling_context(self, curr_target_num_replicas): + total_num_requests = self.get_total_num_requests() + total_queued_requests = self._get_queued_requests() + # NOTE: for non additive aggregation functions, total_running_requests is not + # accurate, consider this is a approximation. + total_running_requests = total_num_requests - total_queued_requests + + autoscaling_context: AutoscalingContext = AutoscalingContext( + deployment_id=self._deployment_id, + deployment_name=self._deployment_id.name, + app_name=self._deployment_id.app_name, + current_num_replicas=len(self._running_replicas), + target_num_replicas=curr_target_num_replicas, + running_replicas=self._running_replicas, + total_num_requests=total_num_requests, + capacity_adjusted_min_replicas=self.get_num_replicas_lower_bound(), + capacity_adjusted_max_replicas=self.get_num_replicas_upper_bound(), + policy_state=( + self._policy_state.copy() if self._policy_state is not None else {} + ), + current_time=time.time(), + config=self._config, + total_queued_requests=total_queued_requests, + total_running_requests=total_running_requests, + aggregated_metrics=self._get_aggregated_custom_metrics(), + raw_metrics=self._get_raw_custom_metrics(), + last_scale_up_time=None, + last_scale_down_time=None, + ) + + return autoscaling_context + + def _collect_replica_running_requests(self) -> List[TimeSeries]: + """Collect running requests timeseries from replicas for aggregation. + + Returns: + List of timeseries data. + """ + timeseries_list = [] + + for replica_id in self._running_replicas: + replica_metric_report = self._replica_metrics.get(replica_id, None) + if ( + replica_metric_report is not None + and RUNNING_REQUESTS_KEY in replica_metric_report.metrics + ): + timeseries_list.append( + replica_metric_report.metrics[RUNNING_REQUESTS_KEY] + ) + + return timeseries_list + + def _collect_handle_queued_requests(self) -> List[TimeSeries]: + """Collect queued requests timeseries from all handles. + + Returns: + List of timeseries data. + """ + timeseries_list = [] + for handle_metric_report in self._handle_requests.values(): + timeseries_list.append(handle_metric_report.queued_requests) + return timeseries_list + + def _collect_handle_running_requests(self) -> List[TimeSeries]: + """Collect running requests timeseries from handles when not collected on replicas. + + Returns: + List of timeseries data. + + Example: + If there are 2 handles, each managing 2 replicas, and the running requests metrics are: + - Handle 1: Replica 1: 5, Replica 2: 7 + - Handle 2: Replica 1: 3, Replica 2: 1 + and the timestamp is 0.1 and 0.2 respectively + Then the returned list will be: + [ + [TimeStampedValue(timestamp=0.1, value=5.0)], + [TimeStampedValue(timestamp=0.2, value=7.0)], + [TimeStampedValue(timestamp=0.1, value=3.0)], + [TimeStampedValue(timestamp=0.2, value=1.0)] + ] + """ + timeseries_list = [] + + for handle_metric in self._handle_requests.values(): + for replica_id in self._running_replicas: + if ( + RUNNING_REQUESTS_KEY not in handle_metric.metrics + or replica_id not in handle_metric.metrics[RUNNING_REQUESTS_KEY] + ): + continue + timeseries_list.append( + handle_metric.metrics[RUNNING_REQUESTS_KEY][replica_id] + ) + + return timeseries_list + + def _merge_and_aggregate_timeseries( + self, + timeseries_list: List[TimeSeries], + ) -> float: + """Aggregate and average a metric from timeseries data using instantaneous merge. + + Args: + timeseries_list: A list of TimeSeries (TimeSeries), where each + TimeSeries represents measurements from a single source (replica, handle, etc.). + Each list is sorted by timestamp ascending. + + Returns: + The time-weighted average of the metric + + Example: + If the timeseries_list is: + [ + [ + TimeStampedValue(timestamp=0.1, value=5.0), + TimeStampedValue(timestamp=0.2, value=7.0), + ], + [ + TimeStampedValue(timestamp=0.2, value=3.0), + TimeStampedValue(timestamp=0.3, value=1.0), + ] + ] + Then the returned value will be: + (5.0*0.1 + 7.0*0.2 + 3.0*0.2 + 1.0*0.3) / (0.1 + 0.2 + 0.2 + 0.3) = 4.5 / 0.8 = 5.625 + """ + + if not timeseries_list: + return 0.0 + + # Use instantaneous merge approach - no arbitrary windowing needed + merged_timeseries = merge_instantaneous_total(timeseries_list) + if merged_timeseries: + # assume that the last recorded metric is valid for last_window_s seconds + last_metric_time = merged_timeseries[-1].timestamp + # we dont want to make any assumption about how long the last metric will be valid + # only conclude that the last metric is valid for last_window_s seconds that is the + # difference between the current time and the last metric recorded time + last_window_s = time.time() - last_metric_time + # adding a check to negative values caused by clock skew + # between replicas and controller. Also add a small epsilon to avoid division by zero + if last_window_s <= 0: + last_window_s = 1e-3 + # Calculate the aggregated metric value + value = aggregate_timeseries( + merged_timeseries, + aggregation_function=self._config.aggregation_function, + last_window_s=last_window_s, + ) + return value if value is not None else 0.0 + + return 0.0 + + def _calculate_total_requests_aggregate_mode(self) -> float: + """Calculate total requests using aggregate metrics mode with timeseries data. + + This method works with raw timeseries metrics data and performs aggregation + at the controller level, providing more accurate and stable metrics compared + to simple mode. + + Processing Steps: + 1. Collect raw timeseries data (eg: running request) from replicas (if available) + 2. Collect queued requests from handles (always tracked at handle level) + 3. Collect raw timeseries data (eg: running request) from handles (if not available from replicas) + 4. Merge timeseries using instantaneous approach for mathematically correct totals + 5. Calculate time-weighted average running requests from the merged timeseries + + Key Differences from Simple Mode: + - Uses raw timeseries data instead of pre-aggregated metrics + - Performs instantaneous merging for exact gauge semantics + - Aggregates at the controller level rather than using pre-computed averages + - Uses time-weighted averaging over the look_back_period_s interval for accurate calculations + + Metrics Collection: + Running requests are collected with either replica-level or handle-level metrics. + + Queued requests are always collected from handles regardless of where + running requests are collected. + + Timeseries Aggregation: + Raw timeseries data from multiple sources is merged using an instantaneous + approach that treats gauges as right-continuous step functions. This provides + mathematically correct totals without arbitrary windowing bias. + + Example with Numbers: + Assume metrics_interval_s = 0.5s, current time = 2.0s + + Step 1: Collect raw timeseries from 2 replicas (r1, r2) + replica_metrics = [ + {"running_requests": [(t=0.2, val=5), (t=0.8, val=7), (t=1.5, val=6)]}, # r1 + {"running_requests": [(t=0.1, val=3), (t=0.9, val=4), (t=1.4, val=8)]} # r2 + ] + + Step 2: Collect queued requests from handles + handle_queued = 2 + 3 = 5 # total from all handles + + Step 3: No handle metrics needed (replica metrics available) + handle_metrics = [] + + Step 4: Merge timeseries using instantaneous approach + # Create delta events: r1 starts at 5 (t=0.2), changes to 7 (t=0.8), then 6 (t=1.5) + # r2 starts at 3 (t=0.1), changes to 4 (t=0.9), then 8 (t=1.4) + # Merged instantaneous total: [(t=0.1, val=3), (t=0.2, val=8), (t=0.8, val=10), (t=0.9, val=11), (t=1.4, val=15), (t=1.5, val=14)] + merged_timeseries = {"running_requests": [(0.1, 3), (0.2, 8), (0.8, 10), (0.9, 11), (1.4, 15), (1.5, 14)]} + + Step 5: Calculate time-weighted average over full timeseries (t=0.1 to t=1.5+0.5=2.0) + # Time-weighted calculation: (3*0.1 + 8*0.6 + 10*0.1 + 11*0.5 + 15*0.1 + 14*0.5) / 2.0 = 10.05 + avg_running = 10.05 + + Final result: total_requests = avg_running + queued = 10.05 + 5 = 15.05 + + Returns: + Total number of requests (average running + queued) calculated from + timeseries data aggregation. + """ + # Collect replica-based running requests (returns List[TimeSeries]) + replica_timeseries = self._collect_replica_running_requests() + metrics_collected_on_replicas = len(replica_timeseries) > 0 + + # Collect queued requests from handles (returns List[TimeSeries]) + queued_timeseries = self._collect_handle_queued_requests() + + if not metrics_collected_on_replicas: + # Collect handle-based running requests if not collected on replicas + handle_timeseries = self._collect_handle_running_requests() + else: + handle_timeseries = [] + + # Collect all timeseries for ongoing requests + ongoing_requests_timeseries = [] + + # Add replica timeseries + ongoing_requests_timeseries.extend(replica_timeseries) + + # Add handle timeseries if replica metrics weren't collected + if not metrics_collected_on_replicas: + ongoing_requests_timeseries.extend(handle_timeseries) + + # Add queued timeseries + ongoing_requests_timeseries.extend(queued_timeseries) + + # Aggregate and add running requests to total + ongoing_requests = self._merge_and_aggregate_timeseries( + ongoing_requests_timeseries + ) + + return ongoing_requests + + def _calculate_total_requests_simple_mode(self) -> float: + """Calculate total requests using simple aggregated metrics mode. + + This method works with pre-aggregated metrics that are computed by averaging + (or other functions) over the past look_back_period_s seconds. + + Metrics Collection: + Metrics can be collected at two levels: + 1. Replica level: Each replica reports one aggregated metric value + 2. Handle level: Each handle reports metrics for multiple replicas + + Replica-Level Metrics Example: + For 3 replicas (r1, r2, r3), metrics might look like: + { + "r1": 10, + "r2": 20, + "r3": 30 + } + Total requests = 10 + 20 + 30 = 60 + + Handle-Level Metrics Example: + For 3 handles (h1, h2, h3), each managing 2 replicas: + - h1 manages r1, r2 + - h2 manages r2, r3 + - h3 manages r3, r1 + + Metrics structure: + { + "h1": {"r1": 10, "r2": 20}, + "h2": {"r2": 20, "r3": 30}, + "h3": {"r3": 30, "r1": 10} + } + + Total requests = 10 + 20 + 20 + 30 + 30 + 10 = 120 + + Note: We can safely sum all handle metrics because each unique request + is counted only once across all handles (no double-counting). + + Queued Requests: + Queued request metrics are always tracked at the handle level, regardless + of whether running request metrics are collected at replicas or handles. + + Returns: + Total number of requests (running + queued) across all replicas/handles. + """ + total_requests = 0 + + for id in self._running_replicas: + if id in self._replica_metrics: + total_requests += self._replica_metrics[id].aggregated_metrics.get( + RUNNING_REQUESTS_KEY, 0 + ) + + metrics_collected_on_replicas = total_requests > 0 + + # Add handle metrics + for handle_metric in self._handle_requests.values(): + total_requests += handle_metric.aggregated_queued_requests + # Add running requests from handles if not collected on replicas + if not metrics_collected_on_replicas: + for replica_id in self._running_replicas: + if replica_id in handle_metric.aggregated_metrics.get( + RUNNING_REQUESTS_KEY, {} + ): + total_requests += handle_metric.aggregated_metrics.get( + RUNNING_REQUESTS_KEY + ).get(replica_id) + return total_requests + + def get_total_num_requests(self) -> float: + """Get average total number of requests aggregated over the past + `look_back_period_s` number of seconds. + + If there are 0 running replicas, then returns the total number + of requests queued at handles + + This code assumes that the metrics are either emmited on handles + or on replicas, but not both. Its the responsibility of the writer + to ensure enclusivity of the metrics. + """ + if RAY_SERVE_AGGREGATE_METRICS_AT_CONTROLLER: + return self._calculate_total_requests_aggregate_mode() + else: + return self._calculate_total_requests_simple_mode() + + def get_replica_metrics(self) -> Dict[ReplicaID, List[TimeSeries]]: + """Get the raw replica metrics dict.""" + metric_values = defaultdict(list) + for id in self._running_replicas: + if id in self._replica_metrics and self._replica_metrics[id].metrics: + for k, v in self._replica_metrics[id].metrics.items(): + metric_values[k].append(v) + + return metric_values + + def _get_queued_requests(self) -> float: + """Calculate the total number of queued requests across all handles. + + Returns: + Sum of queued requests at all handles. Uses aggregated values in simple mode, + or aggregates timeseries data in aggregate mode. + """ + if RAY_SERVE_AGGREGATE_METRICS_AT_CONTROLLER: + # Aggregate mode: collect and aggregate timeseries + queued_timeseries = self._collect_handle_queued_requests() + if not queued_timeseries: + return 0.0 + + return self._merge_and_aggregate_timeseries(queued_timeseries) + else: + # Simple mode: sum pre-aggregated values + return sum( + handle_metric.aggregated_queued_requests + for handle_metric in self._handle_requests.values() + ) + + def _get_aggregated_custom_metrics(self) -> Dict[str, Dict[ReplicaID, float]]: + """Aggregate custom metrics from replica metric reports. + + This method aggregates raw timeseries data from replicas on the controller, + similar to how ongoing requests are aggregated. + + Returns: + Dict mapping metric name to dict of replica ID to aggregated metric value. + """ + aggregated_metrics = defaultdict(dict) + + for replica_id in self._running_replicas: + replica_metric_report = self._replica_metrics.get(replica_id) + if replica_metric_report is None: + continue + + for metric_name, timeseries in replica_metric_report.metrics.items(): + # Aggregate the timeseries for this custom metric + aggregated_value = self._merge_and_aggregate_timeseries([timeseries]) + aggregated_metrics[metric_name][replica_id] = aggregated_value + + return dict(aggregated_metrics) + + def _get_raw_custom_metrics( + self, + ) -> Dict[str, Dict[ReplicaID, TimeSeries]]: + """Extract raw custom metric values from replica metric reports. + + Returns: + Dict mapping metric name to dict of replica ID to raw metric timeseries. + """ + raw_metrics = defaultdict(dict) + + for replica_id in self._running_replicas: + replica_metric_report = self._replica_metrics.get(replica_id) + if replica_metric_report is None: + continue + + for metric_name, timeseries in replica_metric_report.metrics.items(): + # Extract values from TimeStampedValue list + raw_metrics[metric_name][replica_id] = timeseries + + return dict(raw_metrics) + + +class ApplicationAutoscalingState: + """Manages autoscaling for a single application.""" + + def __init__( + self, + app_name: ApplicationName, + ): + self._app_name = app_name + self._deployment_autoscaling_states: Dict[ + DeploymentID, DeploymentAutoscalingState + ] = {} + self._policy: Optional[ + Callable[ + [Dict[DeploymentID, AutoscalingContext]], + Tuple[Dict[DeploymentID, int], Optional[Dict[str, Dict]]], + ] + ] = None + # user defined policy returns a dictionary of state that is persisted between autoscaling decisions + # content of the dictionary is determined by the user defined policy + self._policy_state: Optional[Dict[str, Any]] = None + + @property + def deployments(self): + return self._deployment_autoscaling_states.keys() + + def register( + self, + autoscaling_policy: AutoscalingPolicy, + ): + """Register or update application-level autoscaling config and deployments. + + This will overwrite the deployment-level policies with the application-level policy. + + Args: + autoscaling_policy: The autoscaling policy to register. + """ + self._policy = autoscaling_policy.get_policy() + self._policy_state = {} + + # Log when custom autoscaling policy is used for application + if not autoscaling_policy.is_default_policy_function(): + logger.info( + f"Using custom autoscaling policy '{autoscaling_policy.policy_function}' " + f"for application '{self._app_name}'." + ) + # Record telemetry for custom autoscaling policy usage + ServeUsageTag.CUSTOM_AUTOSCALING_POLICY_USED.record("1") + + def has_policy(self) -> bool: + return self._policy is not None + + def register_deployment( + self, + deployment_id: DeploymentID, + info: DeploymentInfo, + curr_target_num_replicas: int, + ) -> int: + """Register a single deployment under this application.""" + if deployment_id not in self._deployment_autoscaling_states: + self._deployment_autoscaling_states[ + deployment_id + ] = DeploymentAutoscalingState(deployment_id) + + if info.deployment_config.autoscaling_config is None: + raise ValueError( + f"Autoscaling config is not set for deployment {deployment_id}" + ) + + # if the deployment-level policy is not the default policy, and the application has a policy, + # warn the user that the application-level policy will take precedence + if ( + not info.deployment_config.autoscaling_config.policy.is_default_policy_function() + and self.has_policy() + ): + logger.warning( + f"User provided both a deployment-level and an application-level policy for deployment {deployment_id}. " + "The application-level policy will take precedence." + ) + + return self._deployment_autoscaling_states[deployment_id].register( + info, + curr_target_num_replicas, + ) + + def deregister_deployment(self, deployment_id: DeploymentID): + if deployment_id not in self._deployment_autoscaling_states: + logger.warning( + f"Cannot deregister autoscaling state for deployment {deployment_id} because it is not registered" + ) + return + self._deployment_autoscaling_states.pop(deployment_id) + + def should_autoscale_deployment(self, deployment_id: DeploymentID): + return deployment_id in self._deployment_autoscaling_states + + def get_decision_num_replicas( + self, + deployment_to_target_num_replicas: Dict[DeploymentID, int], + _skip_bound_check: bool = False, + ) -> Dict[DeploymentID, int]: + """ + Decide scaling for all deployments in this application by calling + each deployment's autoscaling policy. + """ + if self.has_policy(): + # Using app-level policy + autoscaling_contexts = { + deployment_id: state.get_autoscaling_context( + deployment_to_target_num_replicas[deployment_id] + ) + for deployment_id, state in self._deployment_autoscaling_states.items() + } + + # Policy returns {deployment_name -> decision} + decisions, self._policy_state = self._policy(autoscaling_contexts) + + assert ( + type(decisions) is dict + ), "Autoscaling policy must return a dictionary of deployment_name -> decision_num_replicas" + + # assert that deployment_id is in decisions is valid + for deployment_id in decisions.keys(): + assert ( + deployment_id in self._deployment_autoscaling_states + ), f"Deployment {deployment_id} is not registered" + assert ( + deployment_id in deployment_to_target_num_replicas + ), f"Deployment {deployment_id} is invalid" + + return { + deployment_id: ( + self._deployment_autoscaling_states[deployment_id].apply_bounds( + num_replicas + ) + if not _skip_bound_check + else num_replicas + ) + for deployment_id, num_replicas in decisions.items() + } + else: + # Using deployment-level policy + return { + deployment_id: deployment_autoscaling_state.get_decision_num_replicas( + curr_target_num_replicas=deployment_to_target_num_replicas[ + deployment_id + ], + _skip_bound_check=_skip_bound_check, + ) + for deployment_id, deployment_autoscaling_state in self._deployment_autoscaling_states.items() + } + + def update_running_replica_ids( + self, deployment_id: DeploymentID, running_replicas: List[ReplicaID] + ): + self._deployment_autoscaling_states[deployment_id].update_running_replica_ids( + running_replicas + ) + + def on_replica_stopped(self, replica_id: ReplicaID): + dep_id = replica_id.deployment_id + if dep_id in self._deployment_autoscaling_states: + self._deployment_autoscaling_states[dep_id].on_replica_stopped(replica_id) + + def get_total_num_requests_for_deployment( + self, deployment_id: DeploymentID + ) -> float: + return self._deployment_autoscaling_states[ + deployment_id + ].get_total_num_requests() + + def get_replica_metrics_by_deployment_id(self, deployment_id: DeploymentID): + return self._deployment_autoscaling_states[deployment_id].get_replica_metrics() + + def is_within_bounds( + self, deployment_id: DeploymentID, num_replicas_running_at_target_version: int + ) -> bool: + return self._deployment_autoscaling_states[deployment_id].is_within_bounds( + num_replicas_running_at_target_version + ) + + def record_request_metrics_for_replica( + self, replica_metric_report: ReplicaMetricReport + ): + dep_id = replica_metric_report.replica_id.deployment_id + # Defensively guard against delayed replica metrics arriving + # after the deployment's been deleted + if dep_id in self._deployment_autoscaling_states: + self._deployment_autoscaling_states[ + dep_id + ].record_request_metrics_for_replica(replica_metric_report) + + def record_request_metrics_for_handle( + self, handle_metric_report: HandleMetricReport + ): + dep_id = handle_metric_report.deployment_id + if dep_id in self._deployment_autoscaling_states: + self._deployment_autoscaling_states[ + dep_id + ].record_request_metrics_for_handle(handle_metric_report) + + def drop_stale_handle_metrics(self, alive_serve_actor_ids: Set[str]): + """Drops handle metrics that are no longer valid. + + This includes handles that live on Serve Proxy or replica actors + that have died AND handles from which the controller hasn't + received an update for too long. + """ + for dep_state in self._deployment_autoscaling_states.values(): + dep_state.drop_stale_handle_metrics(alive_serve_actor_ids) + + +class AutoscalingStateManager: + """Manages all things autoscaling related. + + Keeps track of request metrics for each application and its deployments, + and decides on the target number of replicas to autoscale to. + """ + + def __init__(self): + self._app_autoscaling_states: Dict[ + ApplicationName, ApplicationAutoscalingState + ] = {} + + def register_deployment( + self, + deployment_id: DeploymentID, + info: DeploymentInfo, + curr_target_num_replicas: int, + ) -> int: + """Register autoscaling deployment info.""" + assert info.deployment_config.autoscaling_config + app_name = deployment_id.app_name + app_state = self._app_autoscaling_states.setdefault( + app_name, ApplicationAutoscalingState(app_name) + ) + logger.info(f"Registering autoscaling state for deployment {deployment_id}") + return app_state.register_deployment( + deployment_id, info, curr_target_num_replicas + ) + + def deregister_deployment(self, deployment_id: DeploymentID): + """Remove deployment from tracking.""" + app_state = self._app_autoscaling_states.get(deployment_id.app_name) + if app_state: + logger.info( + f"Deregistering autoscaling state for deployment {deployment_id}" + ) + app_state.deregister_deployment(deployment_id) + + def register_application( + self, + app_name: ApplicationName, + autoscaling_policy: AutoscalingPolicy, + ): + app_state = self._app_autoscaling_states.setdefault( + app_name, ApplicationAutoscalingState(app_name) + ) + logger.info(f"Registering autoscaling state for application {app_name}") + app_state.register(autoscaling_policy) + + def deregister_application(self, app_name: ApplicationName): + """Remove application from tracking.""" + if app_name in self._app_autoscaling_states: + logger.info(f"Deregistering autoscaling state for application {app_name}") + self._app_autoscaling_states.pop(app_name, None) + + def _application_has_policy(self, app_name: ApplicationName) -> bool: + return ( + app_name in self._app_autoscaling_states + and self._app_autoscaling_states[app_name].has_policy() + ) + + def get_decision_num_replicas( + self, + app_name: ApplicationName, + deployment_to_target_num_replicas: Dict[DeploymentID, int], + ) -> Dict[DeploymentID, int]: + """ + Decide scaling for all deployments in the application. + + Args: + app_name: The name of the application. + deployment_to_target_num_replicas: A dictionary of deployment_id to target number of replicas. + + Returns: + A dictionary of deployment_id to decision number of replicas. + """ + return self._app_autoscaling_states[app_name].get_decision_num_replicas( + deployment_to_target_num_replicas + ) + + def should_autoscale_application(self, app_name: ApplicationName): + return app_name in self._app_autoscaling_states + + def should_autoscale_deployment(self, deployment_id: DeploymentID): + return ( + deployment_id.app_name in self._app_autoscaling_states + and self._app_autoscaling_states[ + deployment_id.app_name + ].should_autoscale_deployment(deployment_id) + ) + + def update_running_replica_ids( + self, deployment_id: DeploymentID, running_replicas: List[ReplicaID] + ): + app_state = self._app_autoscaling_states.get(deployment_id.app_name) + if app_state: + app_state.update_running_replica_ids(deployment_id, running_replicas) + + def on_replica_stopped(self, replica_id: ReplicaID): + app_state = self._app_autoscaling_states.get(replica_id.deployment_id.app_name) + if app_state: + app_state.on_replica_stopped(replica_id) + + def get_metrics_for_deployment( + self, deployment_id: DeploymentID + ) -> Dict[ReplicaID, List[TimeSeries]]: + if deployment_id.app_name in self._app_autoscaling_states: + return self._app_autoscaling_states[ + deployment_id.app_name + ].get_replica_metrics_by_deployment_id(deployment_id) + else: + return {} + + def get_total_num_requests_for_deployment( + self, deployment_id: DeploymentID + ) -> float: + if deployment_id.app_name in self._app_autoscaling_states: + return self._app_autoscaling_states[ + deployment_id.app_name + ].get_total_num_requests_for_deployment(deployment_id) + else: + return 0 + + def is_within_bounds( + self, deployment_id: DeploymentID, num_replicas_running_at_target_version: int + ) -> bool: + app_state = self._app_autoscaling_states[deployment_id.app_name] + return app_state.is_within_bounds( + deployment_id, num_replicas_running_at_target_version + ) + + def record_request_metrics_for_replica( + self, replica_metric_report: ReplicaMetricReport + ) -> None: + app_state = self._app_autoscaling_states.get( + replica_metric_report.replica_id.deployment_id.app_name + ) + if app_state: + app_state.record_request_metrics_for_replica(replica_metric_report) + + def record_request_metrics_for_handle( + self, + handle_metric_report: HandleMetricReport, + ) -> None: + """Update request metric for a specific handle.""" + app_state = self._app_autoscaling_states.get( + handle_metric_report.deployment_id.app_name + ) + if app_state: + app_state.record_request_metrics_for_handle(handle_metric_report) + + def drop_stale_handle_metrics(self, alive_serve_actor_ids: Set[str]) -> None: + for app_state in self._app_autoscaling_states.values(): + app_state.drop_stale_handle_metrics(alive_serve_actor_ids) diff --git a/lib/python3.12/site-packages/ray/serve/_private/benchmarks/__init__.py b/lib/python3.12/site-packages/ray/serve/_private/benchmarks/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lib/python3.12/site-packages/ray/serve/_private/benchmarks/__pycache__/__init__.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/_private/benchmarks/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..252e3126ac66f4a971d135f64f88774080d56d53 Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/_private/benchmarks/__pycache__/__init__.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/_private/benchmarks/__pycache__/common.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/_private/benchmarks/__pycache__/common.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..99fdd61b1eeb0f8a5d95b66de333b738dd20796d Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/_private/benchmarks/__pycache__/common.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/_private/benchmarks/__pycache__/handle_noop_latency.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/_private/benchmarks/__pycache__/handle_noop_latency.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5cb414e835f74c90bef7409aa4e75c846f33455b Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/_private/benchmarks/__pycache__/handle_noop_latency.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/_private/benchmarks/__pycache__/handle_throughput.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/_private/benchmarks/__pycache__/handle_throughput.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bfa6cee7294e59f2a2c87b88dcb3eb69fe97c285 Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/_private/benchmarks/__pycache__/handle_throughput.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/_private/benchmarks/__pycache__/http_noop_latency.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/_private/benchmarks/__pycache__/http_noop_latency.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e1f0f679efd86345d304952c3ccbc413794c5211 Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/_private/benchmarks/__pycache__/http_noop_latency.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/_private/benchmarks/__pycache__/locust_utils.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/_private/benchmarks/__pycache__/locust_utils.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4f485c9d19145ed1a3660e169e2cbb009d66009d Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/_private/benchmarks/__pycache__/locust_utils.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/_private/benchmarks/__pycache__/microbenchmark.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/_private/benchmarks/__pycache__/microbenchmark.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..744c88368aeb755d7d341776d43c161c406a8495 Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/_private/benchmarks/__pycache__/microbenchmark.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/_private/benchmarks/__pycache__/proxy_benchmark.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/_private/benchmarks/__pycache__/proxy_benchmark.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3b3d777bfd596e072ba5368270249651f5a5eb7e Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/_private/benchmarks/__pycache__/proxy_benchmark.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/_private/benchmarks/common.py b/lib/python3.12/site-packages/ray/serve/_private/benchmarks/common.py new file mode 100644 index 0000000000000000000000000000000000000000..2fe4d727cf17bc934b23f222f95813ed1537bfd1 --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/_private/benchmarks/common.py @@ -0,0 +1,314 @@ +import asyncio +import inspect +import logging +import random +import string +import time +from functools import partial +from typing import Any, Callable, Coroutine, List, Optional, Tuple + +import aiohttp +import aiohttp.client_exceptions +import grpc +import numpy as np +import pandas as pd +from starlette.responses import StreamingResponse +from tqdm import tqdm + +from ray import serve +from ray.serve.generated import serve_pb2, serve_pb2_grpc +from ray.serve.handle import DeploymentHandle + + +async def run_latency_benchmark( + f: Callable, num_requests: int, *, num_warmup_requests: int = 100 +) -> pd.Series: + if inspect.iscoroutinefunction(f): + to_call = f + else: + + async def to_call(): + f() + + latencies = [] + for i in tqdm(range(num_requests + num_warmup_requests)): + start = time.perf_counter() + await to_call() + end = time.perf_counter() + + # Don't include warm-up requests. + if i >= num_warmup_requests: + latencies.append(1000 * (end - start)) + + return pd.Series(latencies) + + +async def run_throughput_benchmark( + fn: Callable[[], List[float]], + multiplier: int = 1, + num_trials: int = 10, + trial_runtime: float = 1, +) -> Tuple[float, float, pd.Series]: + """Benchmarks throughput of a function. + + Args: + fn: The function to benchmark. If this returns anything, it must + return a list of latencies. + multiplier: The number of requests or tokens (or whatever unit + is appropriate for this throughput benchmark) that is + completed in one call to `fn`. + num_trials: The number of trials to run. + trial_runtime: How long each trial should run for. During the + duration of one trial, `fn` will be repeatedly called. + + Returns (mean, stddev, latencies). + """ + # Warmup + start = time.time() + while time.time() - start < 0.1: + await fn() + + # Benchmark + stats = [] + latencies = [] + for _ in tqdm(range(num_trials)): + start = time.perf_counter() + count = 0 + while time.perf_counter() - start < trial_runtime: + res = await fn() + if res: + latencies.extend(res) + + count += 1 + end = time.perf_counter() + stats.append(multiplier * count / (end - start)) + + return round(np.mean(stats), 2), round(np.std(stats), 2), pd.Series(latencies) + + +async def do_single_http_batch( + *, + batch_size: int = 100, + url: str = "http://localhost:8000", + stream: bool = False, +) -> List[float]: + """Sends a batch of http requests and returns e2e latencies.""" + + # By default, aiohttp limits the number of client connections to 100. + # We need to use TCPConnector to configure the limit if batch size + # is greater than 100. + connector = aiohttp.TCPConnector(limit=batch_size) + async with aiohttp.ClientSession( + connector=connector, raise_for_status=True + ) as session: + + async def do_query(): + start = time.perf_counter() + try: + async with session.get(url) as r: + if stream: + async for chunk, _ in r.content.iter_chunks(): + pass + else: + # Read the response to ensure it's consumed + await r.read() + except aiohttp.client_exceptions.ClientConnectionError: + pass + + end = time.perf_counter() + return 1000 * (end - start) + + return await asyncio.gather(*[do_query() for _ in range(batch_size)]) + + +async def do_single_grpc_batch( + *, batch_size: int = 100, target: str = "localhost:9000" +): + channel = grpc.aio.insecure_channel(target) + stub = serve_pb2_grpc.RayServeBenchmarkServiceStub(channel) + payload = serve_pb2.StringData(data="") + + async def do_query(): + start = time.perf_counter() + + await stub.grpc_call(payload) + + end = time.perf_counter() + return 1000 * (end - start) + + return await asyncio.gather(*[do_query() for _ in range(batch_size)]) + + +async def collect_profile_events(coro: Coroutine): + """Collects profiling events using Viztracer""" + + from viztracer import VizTracer + + tracer = VizTracer() + tracer.start() + + await coro + + tracer.stop() + tracer.save() + + +def generate_payload(size: int = 100, chars=string.ascii_uppercase + string.digits): + return "".join(random.choice(chars) for _ in range(size)) + + +class Blackhole: + def sink(self, o): + pass + + +@serve.deployment +class Noop: + def __init__(self): + logging.getLogger("ray.serve").setLevel(logging.WARNING) + + def __call__(self, *args, **kwargs): + return b"" + + +@serve.deployment +class ModelComp: + def __init__(self, child): + logging.getLogger("ray.serve").setLevel(logging.WARNING) + self._child = child + + async def __call__(self, *args, **kwargs): + return await self._child.remote() + + +@serve.deployment +class GrpcDeployment: + def __init__(self): + logging.getLogger("ray.serve").setLevel(logging.WARNING) + + async def grpc_call(self, user_message): + return serve_pb2.ModelOutput(output=9) + + async def call_with_string(self, user_message): + return serve_pb2.ModelOutput(output=9) + + +@serve.deployment +class GrpcModelComp: + def __init__(self, child): + logging.getLogger("ray.serve").setLevel(logging.WARNING) + self._child = child + + async def grpc_call(self, user_message): + await self._child.remote() + return serve_pb2.ModelOutput(output=9) + + async def call_with_string(self, user_message): + await self._child.remote() + return serve_pb2.ModelOutput(output=9) + + +@serve.deployment +class Streamer: + def __init__(self, tokens_per_request: int, inter_token_delay_ms: int = 10): + logging.getLogger("ray.serve").setLevel(logging.WARNING) + self._tokens_per_request = tokens_per_request + self._inter_token_delay_s = inter_token_delay_ms / 1000 + + async def stream(self): + for _ in range(self._tokens_per_request): + await asyncio.sleep(self._inter_token_delay_s) + yield b"hi" + + async def __call__(self): + return StreamingResponse(self.stream()) + + +@serve.deployment +class IntermediateRouter: + def __init__(self, handle: DeploymentHandle): + logging.getLogger("ray.serve").setLevel(logging.WARNING) + self._handle = handle.options(stream=True) + + async def stream(self): + async for token in self._handle.stream.remote(): + yield token + + def __call__(self): + return StreamingResponse(self.stream()) + + +@serve.deployment +class Benchmarker: + def __init__( + self, + handle: DeploymentHandle, + stream: bool = False, + ): + logging.getLogger("ray.serve").setLevel(logging.WARNING) + self._handle = handle.options(stream=stream) + self._stream = stream + + async def do_single_request(self, payload: Any = None) -> float: + """Completes a single unary request. Returns e2e latency in ms.""" + start = time.perf_counter() + + if payload is None: + await self._handle.remote() + else: + await self._handle.remote(payload) + + end = time.perf_counter() + return 1000 * (end - start) + + async def _do_single_stream(self) -> float: + """Consumes a single streaming request. Returns e2e latency in ms.""" + start = time.perf_counter() + + async for r in self._handle.stream.remote(): + pass + + end = time.perf_counter() + return 1000 * (end - start) + + async def _do_single_batch(self, batch_size: int) -> List[float]: + if self._stream: + return await asyncio.gather( + *[self._do_single_stream() for _ in range(batch_size)] + ) + else: + return await asyncio.gather( + *[self.do_single_request() for _ in range(batch_size)] + ) + + async def run_latency_benchmark( + self, *, num_requests: int, payload: Any = None + ) -> pd.Series: + async def f(): + await self.do_single_request(payload) + + return await run_latency_benchmark(f, num_requests=num_requests) + + async def run_throughput_benchmark( + self, + *, + batch_size: int, + num_trials: int, + trial_runtime: float, + tokens_per_request: Optional[float] = None, + ) -> Tuple[float, float]: + if self._stream: + assert tokens_per_request + multiplier = tokens_per_request * batch_size + else: + multiplier = batch_size + + return await run_throughput_benchmark( + fn=partial( + self._do_single_batch, + batch_size=batch_size, + ), + multiplier=multiplier, + num_trials=num_trials, + trial_runtime=trial_runtime, + ) diff --git a/lib/python3.12/site-packages/ray/serve/_private/benchmarks/handle_noop_latency.py b/lib/python3.12/site-packages/ray/serve/_private/benchmarks/handle_noop_latency.py new file mode 100644 index 0000000000000000000000000000000000000000..313e467c6ff0e38f3da21c3dcb40e8b700f9cddf --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/_private/benchmarks/handle_noop_latency.py @@ -0,0 +1,34 @@ +import time + +import click +import pandas as pd + +from ray import serve +from ray.serve._private.benchmarks.common import Benchmarker, Noop +from ray.serve.handle import DeploymentHandle + + +@click.command(help="Benchmark no-op DeploymentHandle latency.") +@click.option("--num-replicas", type=int, default=1) +@click.option("--num-requests", type=int, default=100) +def main(num_replicas: int, num_requests: int): + h: DeploymentHandle = serve.run( + Benchmarker.bind(Noop.options(num_replicas=num_replicas).bind()) + ) + + latencies: pd.Series = h.run_latency_benchmark.remote( + num_requests, + ).result() + + # Let the logs flush to avoid interwoven output. + time.sleep(1) + + print( + "Latency (ms) for noop DeploymentHandle requests " + f"(num_replicas={num_replicas},num_requests={num_requests}):" + ) + print(latencies.describe(percentiles=[0.5, 0.9, 0.95, 0.99])) + + +if __name__ == "__main__": + main() diff --git a/lib/python3.12/site-packages/ray/serve/_private/benchmarks/handle_throughput.py b/lib/python3.12/site-packages/ray/serve/_private/benchmarks/handle_throughput.py new file mode 100644 index 0000000000000000000000000000000000000000..08aa3c215224745cae5bdf7432798dae933b182e --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/_private/benchmarks/handle_throughput.py @@ -0,0 +1,62 @@ +import click + +from ray import serve +from ray.serve._private.benchmarks.common import Benchmarker, Hello +from ray.serve.handle import DeploymentHandle + + +@click.command(help="Benchmark deployment handle throughput.") +@click.option( + "--batch-size", + type=int, + default=100, + help="Number of requests to send to downstream deployment in each trial.", +) +@click.option( + "--num-replicas", + type=int, + default=1, + help="Number of replicas in the downstream deployment.", +) +@click.option( + "--num-trials", + type=int, + default=5, + help="Number of trials of the benchmark to run.", +) +@click.option( + "--trial-runtime", + type=int, + default=1, + help="Duration to run each trial of the benchmark for (seconds).", +) +def main( + batch_size: int, + num_replicas: int, + num_trials: int, + trial_runtime: float, +): + app = Benchmarker.bind( + Hello.options( + num_replicas=num_replicas, ray_actor_options={"num_cpus": 0} + ).bind(), + ) + h: DeploymentHandle = serve.run(app) + + mean, stddev = h.run_throughput_benchmark.remote( + batch_size=batch_size, + num_trials=num_trials, + trial_runtime=trial_runtime, + ).result() + + print( + "DeploymentHandle throughput {}: {} +- {} requests/s".format( + f"(num_replicas={num_replicas}, batch_size={batch_size})", + mean, + stddev, + ) + ) + + +if __name__ == "__main__": + main() diff --git a/lib/python3.12/site-packages/ray/serve/_private/benchmarks/http_noop_latency.py b/lib/python3.12/site-packages/ray/serve/_private/benchmarks/http_noop_latency.py new file mode 100644 index 0000000000000000000000000000000000000000..ab85f5f882da6ace9d771eb2185154fedd90ffd0 --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/_private/benchmarks/http_noop_latency.py @@ -0,0 +1,32 @@ +import asyncio + +import click +import pandas as pd +import requests + +from ray import serve +from ray.serve._private.benchmarks.common import Noop, run_latency_benchmark + + +@click.command(help="Benchmark no-op HTTP latency.") +@click.option("--num-replicas", type=int, default=1) +@click.option("--num-requests", type=int, default=100) +def main(num_replicas: int, num_requests: int): + serve.run(Noop.options(num_replicas=num_replicas).bind()) + + latencies: pd.Series = asyncio.new_event_loop().run_until_complete( + run_latency_benchmark( + lambda: requests.get("http://localhost:8000"), + num_requests=num_requests, + ) + ) + + print( + "Latency (ms) for noop HTTP requests " + f"(num_replicas={num_replicas},num_requests={num_requests}):" + ) + print(latencies.describe(percentiles=[0.5, 0.9, 0.95, 0.99])) + + +if __name__ == "__main__": + main() diff --git a/lib/python3.12/site-packages/ray/serve/_private/benchmarks/locust_utils.py b/lib/python3.12/site-packages/ray/serve/_private/benchmarks/locust_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..7949b69ea52efa4d6b3973520f726233b07d7e2e --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/_private/benchmarks/locust_utils.py @@ -0,0 +1,279 @@ +import argparse +import logging +import time +from dataclasses import asdict, dataclass +from typing import Any, Dict, List + +from ray.serve._private.utils import generate_request_id + +logger = logging.getLogger(__file__) +logging.basicConfig(level=logging.INFO) + +MASTER_PORT = 5557 + + +@dataclass +class LocustStage: + duration_s: int + users: int + spawn_rate: float + + +@dataclass +class PerformanceStats: + p50_latency: float + p90_latency: float + p99_latency: float + rps: float + + +@dataclass +class LocustTestResults: + history: List[Dict] + total_requests: int + num_failures: int + avg_latency: float + p50_latency: float + p90_latency: float + p99_latency: float + avg_rps: float + stats_in_stages: List[PerformanceStats] + + +@dataclass +class FailedRequest: + request_id: str + status_code: int + exception: str + response_time_ms: float + start_time_s: float + + +class LocustClient: + def __init__( + self, + host_url: str, + token: str, + data: Dict[str, Any] = None, + ): + from locust import FastHttpUser, constant, events, task + from locust.contrib.fasthttp import FastResponse + + self.errors = [] + self.stats_in_stages: List[PerformanceStats] = [] + + class EndpointUser(FastHttpUser): + wait_time = constant(0) + failed_requests = [] + host = host_url + + @task + def test(self): + request_id = generate_request_id() + headers = ( + {"Authorization": f"Bearer {token}", "X-Request-ID": request_id} + if token + else None + ) + with self.client.get( + "", headers=headers, json=data, catch_response=True + ) as r: + r.request_meta["context"]["request_id"] = request_id + + @events.request.add_listener + def on_request( + response: FastResponse, + exception, + context, + start_time: float, + response_time: float, + **kwargs, + ): + if exception and response.status_code != 0: + request_id = context["request_id"] + print( + f"Request '{request_id}' failed with exception:\n" + f"{exception}\n{response.text}" + ) + + if response.status_code != 0: + response.encoding = "utf-8" + err = FailedRequest( + request_id=request_id, + status_code=response.status_code, + exception=response.text, + response_time_ms=response_time, + start_time_s=start_time, + ) + self.errors.append(err) + print( + f"Request '{request_id}' failed with exception:\n" + f"{exception}\n{response.text}" + ) + + self.user_class = EndpointUser + + +def on_stage_finished(master_runner, stats_in_stages): + stats_entry_key = ("", "GET") + stats_entry = master_runner.stats.entries.get(stats_entry_key) + + stats_in_stages.append( + PerformanceStats( + p50_latency=stats_entry.get_current_response_time_percentile(0.5), + p90_latency=stats_entry.get_current_response_time_percentile(0.9), + p99_latency=stats_entry.get_current_response_time_percentile(0.99), + rps=stats_entry.current_rps, + ) + ) + + +def run_locust_worker( + master_address: str, host_url: str, token: str, data: Dict[str, Any] +): + import locust + from locust.env import Environment + from locust.log import setup_logging + + setup_logging("INFO") + client = LocustClient(host_url=host_url, token=token, data=data) + env = Environment(user_classes=[client.user_class], events=locust.events) + + runner = env.create_worker_runner( + master_host=master_address, master_port=MASTER_PORT + ) + runner.greenlet.join() + + if client.errors: + raise RuntimeError(f"There were {len(client.errors)} errors: {client.errors}") + + +def run_locust_master( + host_url: str, + token: str, + expected_num_workers: int, + stages: List[LocustStage], + wait_for_workers_timeout_s: float, +): + import gevent + import locust + from locust import LoadTestShape + from locust.env import Environment + from locust.stats import ( + get_error_report_summary, + get_percentile_stats_summary, + get_stats_summary, + stats_history, + stats_printer, + ) + + client = LocustClient(host_url, token) + + class StagesShape(LoadTestShape): + curr_stage_ix = 0 + + def tick(cls): + run_time = cls.get_run_time() + prefix_time = 0 + for i, stage in enumerate(stages): + prefix_time += stage.duration_s + + if run_time < prefix_time: + if i != cls.curr_stage_ix: + on_stage_finished(master_runner, client.stats_in_stages) + cls.curr_stage_ix = i + + current_stage = stages[cls.curr_stage_ix] + return current_stage.users, current_stage.spawn_rate + + # End of stage test + on_stage_finished(master_runner, client.stats_in_stages) + + master_env = Environment( + user_classes=[client.user_class], + shape_class=StagesShape(), + events=locust.events, + ) + master_runner = master_env.create_master_runner("*", MASTER_PORT) + + start = time.time() + while len(master_runner.clients.ready) < expected_num_workers: + if time.time() - start > wait_for_workers_timeout_s: + raise RuntimeError( + f"Timed out waiting for {expected_num_workers} workers to " + "connect to Locust master." + ) + + print( + f"Waiting for workers to be ready, " + f"{len(master_runner.clients.ready)} " + f"of {expected_num_workers} ready." + ) + time.sleep(1) + + # Periodically output current stats (each entry is aggregated + # stats over the past 10 seconds, by default) + gevent.spawn(stats_printer(master_env.stats)) + gevent.spawn(stats_history, master_runner) + + # Start test & wait for the shape test to finish + master_runner.start_shape() + master_runner.shape_greenlet.join() + # Send quit signal to all locust workers + master_runner.quit() + + # Print stats + for line in get_stats_summary(master_runner.stats, current=False): + print(line) + # Print percentile stats + for line in get_percentile_stats_summary(master_runner.stats): + print(line) + # Print error report + if master_runner.stats.errors: + for line in get_error_report_summary(master_runner.stats): + print(line) + + stats_entry_key = ("", "GET") + stats_entry = master_runner.stats.entries.get(stats_entry_key) + results = LocustTestResults( + history=master_runner.stats.history, + total_requests=master_runner.stats.num_requests, + num_failures=master_runner.stats.num_failures, + avg_latency=stats_entry.avg_response_time, + p50_latency=stats_entry.get_response_time_percentile(0.5), + p90_latency=stats_entry.get_response_time_percentile(0.9), + p99_latency=stats_entry.get_response_time_percentile(0.99), + avg_rps=stats_entry.total_rps, + stats_in_stages=client.stats_in_stages, + ) + return asdict(results) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--worker-type", type=str, required=True) + parser.add_argument("--host-url", type=str, required=True) + parser.add_argument("--token", type=str, required=True) + parser.add_argument("--master-address", type=str, required=False) + parser.add_argument("--expected-num-workers", type=int, required=False) + parser.add_argument("--stages", type=str, required=False) + parser.add_argument("--wait-for-workers-timeout-s", type=float, required=False) + args = parser.parse_args() + host_url = args.host_url + token = args.token + if args.worker_type == "master": + results = run_locust_master( + host_url, + token, + args.expected_num_workers, + args.stages, + args.wait_for_workers_timeout_s, + ) + else: + results = run_locust_worker(args.master_address, host_url, token, args.data) + + print(results) + + +if __name__ == "__main__": + main() diff --git a/lib/python3.12/site-packages/ray/serve/_private/benchmarks/microbenchmark.py b/lib/python3.12/site-packages/ray/serve/_private/benchmarks/microbenchmark.py new file mode 100644 index 0000000000000000000000000000000000000000..176298d0f26dd979bc93366e24605a73bd3c85c5 --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/_private/benchmarks/microbenchmark.py @@ -0,0 +1,182 @@ +# Runs several scenarios with varying max batch size, max concurrent queries, +# number of replicas, and with intermediate serve handles (to simulate ensemble +# models) either on or off. + +import asyncio +import logging +from pprint import pprint +from typing import Dict, Union + +import aiohttp +from starlette.requests import Request + +import ray +from ray import serve +from ray.serve._private.benchmarks.common import run_throughput_benchmark +from ray.serve.handle import DeploymentHandle + +NUM_CLIENTS = 8 +CALLS_PER_BATCH = 100 + + +async def fetch(session, data): + async with session.get("http://localhost:8000/", data=data) as response: + response = await response.text() + assert response == "ok", response + + +@ray.remote +class Client: + def ready(self): + return "ok" + + async def do_queries(self, num, data): + async with aiohttp.ClientSession() as session: + for _ in range(num): + await fetch(session, data) + + +def build_app( + intermediate_handles: bool, + num_replicas: int, + max_batch_size: int, + max_ongoing_requests: int, +): + @serve.deployment(max_ongoing_requests=1000) + class Upstream: + def __init__(self, handle: DeploymentHandle): + self._handle = handle + + # Turn off access log. + logging.getLogger("ray.serve").setLevel(logging.WARNING) + + async def __call__(self, req: Request): + return await self._handle.remote(await req.body()) + + @serve.deployment( + num_replicas=num_replicas, + max_ongoing_requests=max_ongoing_requests, + ) + class Downstream: + def __init__(self): + # Turn off access log. + logging.getLogger("ray.serve").setLevel(logging.WARNING) + + @serve.batch(max_batch_size=max_batch_size) + async def batch(self, reqs): + return [b"ok"] * len(reqs) + + async def __call__(self, req: Union[bytes, Request]): + if max_batch_size > 1: + return await self.batch(req) + else: + return b"ok" + + if intermediate_handles: + return Upstream.bind(Downstream.bind()) + else: + return Downstream.bind() + + +async def trial( + intermediate_handles: bool, + num_replicas: int, + max_batch_size: int, + max_ongoing_requests: int, + data_size: str, +) -> Dict[str, float]: + results = {} + + trial_key_base = ( + f"replica:{num_replicas}/batch_size:{max_batch_size}/" + f"concurrent_queries:{max_ongoing_requests}/" + f"data_size:{data_size}/intermediate_handle:{intermediate_handles}" + ) + + print( + f"intermediate_handles={intermediate_handles}," + f"num_replicas={num_replicas}," + f"max_batch_size={max_batch_size}," + f"max_ongoing_requests={max_ongoing_requests}," + f"data_size={data_size}" + ) + + app = build_app( + intermediate_handles, num_replicas, max_batch_size, max_ongoing_requests + ) + serve.run(app) + + if data_size == "small": + data = None + elif data_size == "large": + data = b"a" * 1024 * 1024 + else: + raise ValueError("data_size should be 'small' or 'large'.") + + async with aiohttp.ClientSession() as session: + + async def single_client(): + for _ in range(CALLS_PER_BATCH): + await fetch(session, data) + + single_client_avg_tps, single_client_std_tps = await run_throughput_benchmark( + single_client, + multiplier=CALLS_PER_BATCH, + ) + print( + "\t{} {} +- {} requests/s".format( + "single client {} data".format(data_size), + single_client_avg_tps, + single_client_std_tps, + ) + ) + key = f"num_client:1/{trial_key_base}" + results[key] = single_client_avg_tps + + clients = [Client.remote() for _ in range(NUM_CLIENTS)] + ray.get([client.ready.remote() for client in clients]) + + async def many_clients(): + ray.get([a.do_queries.remote(CALLS_PER_BATCH, data) for a in clients]) + + multi_client_avg_tps, _ = await run_throughput_benchmark( + many_clients, + multiplier=CALLS_PER_BATCH * len(clients), + ) + + results[f"num_client:{len(clients)}/{trial_key_base}"] = multi_client_avg_tps + return results + + +async def main(): + results = {} + for intermediate_handles in [False, True]: + for num_replicas in [1, 8]: + for max_batch_size, max_ongoing_requests in [ + (1, 1), + (1, 10000), + (10000, 10000), + ]: + # TODO(edoakes): large data causes broken pipe errors. + for data_size in ["small"]: + results.update( + await trial( + intermediate_handles, + num_replicas, + max_batch_size, + max_ongoing_requests, + data_size, + ) + ) + + print("Results from all conditions:") + pprint(results) + return results + + +if __name__ == "__main__": + ray.init() + serve.start() + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + loop.run_until_complete(main()) diff --git a/lib/python3.12/site-packages/ray/serve/_private/benchmarks/proxy_benchmark.py b/lib/python3.12/site-packages/ray/serve/_private/benchmarks/proxy_benchmark.py new file mode 100644 index 0000000000000000000000000000000000000000..8f3eca322f29c1c030637195f89308ea1a3e1238 --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/_private/benchmarks/proxy_benchmark.py @@ -0,0 +1,294 @@ +# Runs some request ping to compare HTTP and gRPC performances in TPS and latency. +# Note: this takes around 1 hour to run. + +import asyncio +import json +import logging +import time +from random import random +from typing import Callable, Dict + +import aiohttp +import numpy as np +import pandas as pd +from grpc import aio +from starlette.requests import Request + +import ray +from ray import serve +from ray.serve._private.common import RequestProtocol +from ray.serve.config import gRPCOptions +from ray.serve.generated import serve_pb2, serve_pb2_grpc +from ray.serve.handle import DeploymentHandle + +CALLS_PER_BATCH = 100 +DELTA = 10**-7 + + +async def get_query_tps(name: str, fn: Callable, multiplier: int = CALLS_PER_BATCH): + """Get query TPS. + + Run the function for 0.5 seconds 10 times to calculate how many requests can + be completed. And use those stats to calculate the mean and std of TPS. + """ + # warmup + start = time.time() + while time.time() - start < 0.1: + await fn() + # real run + stats = [] + for _ in range(10): + count = 0 + start = time.time() + while time.time() - start < 0.5: + await fn() + count += 1 + end = time.time() + stats.append(multiplier * count / (end - start)) + tps_mean = round(np.mean(stats), 2) + tps_std = round(np.std(stats), 2) + print(f"\t{name} {tps_mean} +- {tps_std} requests/s") + + return tps_mean, tps_std + + +async def get_query_latencies(name: str, fn: Callable): + """Get query latencies. + + Take all the latencies from the function and calculate the mean and std. + """ + many_client_results = np.asarray(await fn()) + many_client_results.flatten() + latency_ms_mean = round(np.mean(many_client_results) * 1000, 2) + latency_ms_std = round(np.std(many_client_results) * 1000, 2) + print(f"\t{name} {latency_ms_mean} +- {latency_ms_std} ms") + + return latency_ms_mean, latency_ms_std + + +async def fetch_http(session, data): + data_json = {"nums": data} + response = await session.get("http://localhost:8000/", json=data_json) + response_text = await response.read() + float(response_text.decode()) + + +async def fetch_grpc(stub, data): + result = await stub.grpc_call(serve_pb2.RawData(nums=data)) + _ = result.output + + +@ray.remote +class HTTPClient: + def ready(self): + return "ok" + + async def do_queries(self, num, data): + async with aiohttp.ClientSession() as session: + for _ in range(num): + await fetch_http(session, data) + + async def time_queries(self, num, data): + stats = [] + async with aiohttp.ClientSession() as session: + for _ in range(num): + start = time.time() + await fetch_http(session, data) + end = time.time() + stats.append(end - start) + + return stats + + +@ray.remote +class gRPCClient: + def __init__(self): + channel = aio.insecure_channel("localhost:9000") + self.stub = serve_pb2_grpc.RayServeBenchmarkServiceStub(channel) + + def ready(self): + return "ok" + + async def do_queries(self, num, data): + for _ in range(num): + await fetch_grpc(self.stub, data) + + async def time_queries(self, num, data): + stats = [] + for _ in range(num): + start = time.time() + await fetch_grpc(self.stub, data) + end = time.time() + stats.append(end - start) + return stats + + +def build_app( + num_replicas: int, + max_ongoing_requests: int, + data_size: int, +): + @serve.deployment(max_ongoing_requests=1000) + class DataPreprocessing: + def __init__(self, handle: DeploymentHandle): + self._handle = handle + + # Turn off access log. + logging.getLogger("ray.serve").setLevel(logging.WARNING) + + def normalize(self, raw: np.ndarray) -> np.ndarray: + return (raw - np.min(raw)) / (np.max(raw) - np.min(raw) + DELTA) + + async def __call__(self, req: Request): + """HTTP entrypoint. + + It parses the request, normalize the data, and send to model for inference. + """ + body = json.loads(await req.body()) + raw = np.asarray(body["nums"]) + processed = self.normalize(raw) + return await self._handle.remote(processed) + + async def grpc_call(self, raq_data): + """gRPC entrypoint. + + It parses the request, normalize the data, and send to model for inference. + """ + raw = np.asarray(raq_data.nums) + processed = self.normalize(raw) + output = await self._handle.remote(processed) + return serve_pb2.ModelOutput(output=output) + + async def call_with_string(self, raq_data): + """gRPC entrypoint.""" + return serve_pb2.ModelOutput(output=0) + + @serve.deployment( + num_replicas=num_replicas, + max_ongoing_requests=max_ongoing_requests, + ) + class ModelInference: + def __init__(self): + # Turn off access log. + logging.getLogger("ray.serve").setLevel(logging.WARNING) + self._model = np.random.randn(data_size, data_size) + + async def __call__(self, processed: np.ndarray) -> float: + # Run a dot product with a random matrix to simulate a model inference. + model_output = np.dot(processed, self._model) + return sum(model_output) + + return DataPreprocessing.bind(ModelInference.bind()) + + +async def trial( + num_replicas: int, + max_ongoing_requests: int, + data_size: int, + num_clients: int, + proxy: RequestProtocol, +) -> Dict[str, float]: + # Generate input data as array of random floats. + data = [random() for _ in range(data_size)] + + # Build and deploy the app. + app = build_app( + num_replicas=num_replicas, + max_ongoing_requests=max_ongoing_requests, + data_size=data_size, + ) + serve.run(app) + + # Start clients. + if proxy == RequestProtocol.GRPC: + clients = [gRPCClient.remote() for _ in range(num_clients)] + elif proxy == RequestProtocol.HTTP: + clients = [HTTPClient.remote() for _ in range(num_clients)] + ray.get([client.ready.remote() for client in clients]) + + async def client_time_queries(): + return ray.get([a.time_queries.remote(CALLS_PER_BATCH, data) for a in clients]) + + async def client_do_queries(): + ray.get([a.do_queries.remote(CALLS_PER_BATCH, data) for a in clients]) + + trial_key_base = ( + f"proxy:{proxy}/" + f"num_client:{num_clients}/" + f"replica:{num_replicas}/" + f"concurrent_queries:{max_ongoing_requests}/" + f"data_size:{data_size}" + ) + tps_mean, tps_sdt = await get_query_tps( + trial_key_base, + client_do_queries, + ) + latency_ms_mean, latency_ms_std = await get_query_latencies( + trial_key_base, + client_time_queries, + ) + + results = { + "proxy": proxy.value, + "num_client": num_clients, + "replica": num_replicas, + "concurrent_queries": max_ongoing_requests, + "data_size": data_size, + "tps_mean": tps_mean, + "tps_sdt": tps_sdt, + "latency_ms_mean": latency_ms_mean, + "latency_ms_std": latency_ms_std, + } + + return results + + +async def main(): + start_time = time.time() + results = [] + for num_replicas in [1, 8]: + for max_ongoing_requests in [1, 10_000]: + for data_size in [1, 100, 10_000]: + for num_clients in [1, 8]: + for proxy in [RequestProtocol.GRPC, RequestProtocol.HTTP]: + results.append( + await trial( + num_replicas=num_replicas, + max_ongoing_requests=max_ongoing_requests, + data_size=data_size, + num_clients=num_clients, + proxy=proxy, + ) + ) + + print(f"Total time: {time.time() - start_time}s") + print("results", results) + + df = pd.DataFrame.from_dict(results) + df = df.sort_values( + by=["proxy", "num_client", "replica", "concurrent_queries", "data_size"] + ) + print("Results from all conditions:") + # Print the results in with tab separated so we can copy into google sheets. + for i in range(len(df.index)): + row = list(df.iloc[i]) + print("\t".join(map(str, row))) + + +if __name__ == "__main__": + ray.init() + + grpc_port = 9000 + grpc_servicer_functions = [ + "ray.serve.generated.serve_pb2_grpc." + "add_RayServeBenchmarkServiceServicer_to_server", + ] + serve.start( + grpc_options=gRPCOptions( + port=grpc_port, + grpc_servicer_functions=grpc_servicer_functions, + ) + ) + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + loop.run_until_complete(main()) diff --git a/lib/python3.12/site-packages/ray/serve/_private/benchmarks/serialization/__init__.py b/lib/python3.12/site-packages/ray/serve/_private/benchmarks/serialization/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lib/python3.12/site-packages/ray/serve/_private/benchmarks/serialization/__pycache__/__init__.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/_private/benchmarks/serialization/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1bec7d393957a3323cf3aaff926d2bdbbd63d4a8 Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/_private/benchmarks/serialization/__pycache__/__init__.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/_private/benchmarks/serialization/__pycache__/common.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/_private/benchmarks/serialization/__pycache__/common.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7f9ab41220ed619f0679e9bb55b12b10bd0666a8 Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/_private/benchmarks/serialization/__pycache__/common.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/_private/benchmarks/serialization/common.py b/lib/python3.12/site-packages/ray/serve/_private/benchmarks/serialization/common.py new file mode 100644 index 0000000000000000000000000000000000000000..41d535ceaf365044226661c9244312f14d2b260a --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/_private/benchmarks/serialization/common.py @@ -0,0 +1,29 @@ +from dataclasses import dataclass +from typing import List, Optional + +from pydantic import BaseModel + +# +# NOTE: PLEASE READ CAREFULLY BEFORE CHANGING +# +# Payloads in this module are purposefully extracted from benchmark file to force +# Ray's cloudpickle behavior when it does NOT serialize the class definition itself +# along with its payload (instead relying on it being imported) +# + + +class PayloadPydantic(BaseModel): + text: Optional[str] = None + floats: Optional[List[float]] = None + ints: Optional[List[int]] = None + ts: Optional[float] = None + reason: Optional[str] = None + + +@dataclass +class PayloadDataclass: + text: Optional[str] = None + floats: Optional[List[float]] = None + ints: Optional[List[int]] = None + ts: Optional[float] = None + reason: Optional[str] = None diff --git a/lib/python3.12/site-packages/ray/serve/_private/benchmarks/streaming/__init__.py b/lib/python3.12/site-packages/ray/serve/_private/benchmarks/streaming/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lib/python3.12/site-packages/ray/serve/_private/benchmarks/streaming/__pycache__/__init__.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/_private/benchmarks/streaming/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ad2c918c55cd7cb8f0a8593a428633d054bfd933 Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/_private/benchmarks/streaming/__pycache__/__init__.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/_private/benchmarks/streaming/__pycache__/common.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/_private/benchmarks/streaming/__pycache__/common.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6d9dfb56c2ffa4b6b1390fb44a8f03bb3557a26d Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/_private/benchmarks/streaming/__pycache__/common.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/_private/benchmarks/streaming/__pycache__/streaming_core_throughput.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/_private/benchmarks/streaming/__pycache__/streaming_core_throughput.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4e49a750df5f990e5e23444bbfbc723c1cc8c967 Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/_private/benchmarks/streaming/__pycache__/streaming_core_throughput.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/_private/benchmarks/streaming/__pycache__/streaming_grpc_throughput.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/_private/benchmarks/streaming/__pycache__/streaming_grpc_throughput.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1f280ba6615a05c5cc5146c346d4726b9d37fbd4 Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/_private/benchmarks/streaming/__pycache__/streaming_grpc_throughput.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/_private/benchmarks/streaming/__pycache__/streaming_handle_throughput.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/_private/benchmarks/streaming/__pycache__/streaming_handle_throughput.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d2ae222a29cfa95d725392a5663d09f20c033a17 Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/_private/benchmarks/streaming/__pycache__/streaming_handle_throughput.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/_private/benchmarks/streaming/__pycache__/streaming_http_throughput.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/_private/benchmarks/streaming/__pycache__/streaming_http_throughput.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d6263383c5dcf79fd3671a172cc72b15c1e38d20 Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/_private/benchmarks/streaming/__pycache__/streaming_http_throughput.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/_private/benchmarks/streaming/_grpc/__init__.py b/lib/python3.12/site-packages/ray/serve/_private/benchmarks/streaming/_grpc/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lib/python3.12/site-packages/ray/serve/_private/benchmarks/streaming/_grpc/__pycache__/__init__.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/_private/benchmarks/streaming/_grpc/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2c0831c48457b8018e16f4a52e23f0185bc27e0b Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/_private/benchmarks/streaming/_grpc/__pycache__/__init__.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/_private/benchmarks/streaming/_grpc/__pycache__/grpc_server.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/_private/benchmarks/streaming/_grpc/__pycache__/grpc_server.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ff57814068b943f0113ada8bbe055af711fe0da1 Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/_private/benchmarks/streaming/_grpc/__pycache__/grpc_server.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/_private/benchmarks/streaming/_grpc/__pycache__/test_server_pb2_grpc.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/_private/benchmarks/streaming/_grpc/__pycache__/test_server_pb2_grpc.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3851d4730aabb7569bc424fc070a51684dc3d632 Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/_private/benchmarks/streaming/_grpc/__pycache__/test_server_pb2_grpc.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/_private/benchmarks/streaming/_grpc/grpc_server.py b/lib/python3.12/site-packages/ray/serve/_private/benchmarks/streaming/_grpc/grpc_server.py new file mode 100644 index 0000000000000000000000000000000000000000..40f183216c79c545e623b1e3158c3de9138c439a --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/_private/benchmarks/streaming/_grpc/grpc_server.py @@ -0,0 +1,60 @@ +import grpc + +from ray.serve._private.benchmarks.streaming._grpc import ( + test_server_pb2, + test_server_pb2_grpc, +) + + +async def _async_list(async_iterator): + items = [] + async for item in async_iterator: + items.append(item) + + return items + + +class TestGRPCServer(test_server_pb2_grpc.GRPCTestServerServicer): + def __init__(self, tokens_per_request): + self._tokens_per_request = tokens_per_request + + async def Unary(self, request, context): + if request.request_data == "error": + await context.abort( + code=grpc.StatusCode.INTERNAL, + details="unary rpc error", + ) + + return test_server_pb2.Response(response_data="OK") + + async def ClientStreaming(self, request_iterator, context): + data = await _async_list(request_iterator) + + if data and data[0].request_data == "error": + await context.abort( + code=grpc.StatusCode.INTERNAL, + details="client streaming rpc error", + ) + + return test_server_pb2.Response(response_data="OK") + + async def ServerStreaming(self, request, context): + if request.request_data == "error": + await context.abort( + code=grpc.StatusCode.INTERNAL, + details="OK", + ) + + for i in range(self._tokens_per_request): + yield test_server_pb2.Response(response_data="OK") + + async def BidiStreaming(self, request_iterator, context): + data = await _async_list(request_iterator) + if data and data[0].request_data == "error": + await context.abort( + code=grpc.StatusCode.INTERNAL, + details="bidi-streaming rpc error", + ) + + for i in range(self._tokens_per_request): + yield test_server_pb2.Response(response_data="OK") diff --git a/lib/python3.12/site-packages/ray/serve/_private/benchmarks/streaming/_grpc/test_server_pb2_grpc.py b/lib/python3.12/site-packages/ray/serve/_private/benchmarks/streaming/_grpc/test_server_pb2_grpc.py new file mode 100644 index 0000000000000000000000000000000000000000..96d5ebfbfd531cdb730b3f5e77b38c8982a09d57 --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/_private/benchmarks/streaming/_grpc/test_server_pb2_grpc.py @@ -0,0 +1,216 @@ +# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! +"""Client and server classes corresponding to protobuf-defined services.""" +import grpc + +from ray.serve._private.benchmarks.streaming._grpc import ( + test_server_pb2 as backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2, +) + + +class GRPCTestServerStub(object): + """Missing associated documentation comment in .proto file.""" + + def __init__(self, channel): + """Constructor. + + Args: + channel: A grpc.Channel. + """ + self.Unary = channel.unary_unary( + "/GRPCTestServer/Unary", + request_serializer=backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2.Request.SerializeToString, + response_deserializer=backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2.Response.FromString, + ) + self.ClientStreaming = channel.stream_unary( + "/GRPCTestServer/ClientStreaming", + request_serializer=backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2.Request.SerializeToString, + response_deserializer=backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2.Response.FromString, + ) + self.ServerStreaming = channel.unary_stream( + "/GRPCTestServer/ServerStreaming", + request_serializer=backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2.Request.SerializeToString, + response_deserializer=backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2.Response.FromString, + ) + self.BidiStreaming = channel.stream_stream( + "/GRPCTestServer/BidiStreaming", + request_serializer=backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2.Request.SerializeToString, + response_deserializer=backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2.Response.FromString, + ) + + +class GRPCTestServerServicer(object): + """Missing associated documentation comment in .proto file.""" + + def Unary(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details("Method not implemented!") + raise NotImplementedError("Method not implemented!") + + def ClientStreaming(self, request_iterator, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details("Method not implemented!") + raise NotImplementedError("Method not implemented!") + + def ServerStreaming(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details("Method not implemented!") + raise NotImplementedError("Method not implemented!") + + def BidiStreaming(self, request_iterator, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details("Method not implemented!") + raise NotImplementedError("Method not implemented!") + + +def add_GRPCTestServerServicer_to_server(servicer, server): + rpc_method_handlers = { + "Unary": grpc.unary_unary_rpc_method_handler( + servicer.Unary, + request_deserializer=backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2.Request.FromString, + response_serializer=backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2.Response.SerializeToString, + ), + "ClientStreaming": grpc.stream_unary_rpc_method_handler( + servicer.ClientStreaming, + request_deserializer=backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2.Request.FromString, + response_serializer=backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2.Response.SerializeToString, + ), + "ServerStreaming": grpc.unary_stream_rpc_method_handler( + servicer.ServerStreaming, + request_deserializer=backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2.Request.FromString, + response_serializer=backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2.Response.SerializeToString, + ), + "BidiStreaming": grpc.stream_stream_rpc_method_handler( + servicer.BidiStreaming, + request_deserializer=backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2.Request.FromString, + response_serializer=backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2.Response.SerializeToString, + ), + } + generic_handler = grpc.method_handlers_generic_handler( + "GRPCTestServer", rpc_method_handlers + ) + server.add_generic_rpc_handlers((generic_handler,)) + + +# This class is part of an EXPERIMENTAL API. +class GRPCTestServer(object): + """Missing associated documentation comment in .proto file.""" + + @staticmethod + def Unary( + request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None, + ): + return grpc.experimental.unary_unary( + request, + target, + "/GRPCTestServer/Unary", + backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2.Request.SerializeToString, + backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2.Response.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + ) + + @staticmethod + def ClientStreaming( + request_iterator, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None, + ): + return grpc.experimental.stream_unary( + request_iterator, + target, + "/GRPCTestServer/ClientStreaming", + backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2.Request.SerializeToString, + backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2.Response.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + ) + + @staticmethod + def ServerStreaming( + request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None, + ): + return grpc.experimental.unary_stream( + request, + target, + "/GRPCTestServer/ServerStreaming", + backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2.Request.SerializeToString, + backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2.Response.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + ) + + @staticmethod + def BidiStreaming( + request_iterator, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None, + ): + return grpc.experimental.stream_stream( + request_iterator, + target, + "/GRPCTestServer/BidiStreaming", + backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2.Request.SerializeToString, + backend_dot_server_dot_common_dot_clients_dot_grpc_dot_proto_dot_test__server__pb2.Response.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + ) diff --git a/lib/python3.12/site-packages/ray/serve/_private/benchmarks/streaming/common.py b/lib/python3.12/site-packages/ray/serve/_private/benchmarks/streaming/common.py new file mode 100644 index 0000000000000000000000000000000000000000..f5378565f0f089568c530b43167b770ed7db4315 --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/_private/benchmarks/streaming/common.py @@ -0,0 +1,123 @@ +import abc +import asyncio +import enum +import logging +import time +from typing import Tuple, Union + +import numpy as np + +from ray.actor import ActorHandle +from ray.runtime_env import RuntimeEnv +from ray.serve._private.benchmarks.common import Blackhole, run_throughput_benchmark +from ray.serve._private.benchmarks.serialization.common import PayloadPydantic +from ray.serve.handle import DeploymentHandle + +GRPC_DEBUG_RUNTIME_ENV = RuntimeEnv( + env_vars={"GRPC_TRACE": "http", "GRPC_VERBOSITY": "debug"}, +) + + +class IOMode(enum.Enum): + SYNC = "SYNC" + ASYNC = "ASYNC" + + +class Endpoint: + def __init__(self, tokens_per_request: int): + self._tokens_per_request = tokens_per_request + # Switch off logging to minimize its impact + logging.getLogger("ray").setLevel(logging.WARNING) + logging.getLogger("ray.serve").setLevel(logging.WARNING) + + def stream(self): + payload = PayloadPydantic( + text="Test output", + floats=[float(f) for f in range(1, 100)], + ints=list(range(1, 100)), + ts=time.time(), + reason="Success!", + ) + + for i in range(self._tokens_per_request): + yield payload + + async def aio_stream(self): + payload = PayloadPydantic( + text="Test output", + floats=[float(f) for f in range(1, 100)], + ints=list(range(1, 100)), + ts=time.time(), + reason="Success!", + ) + + for i in range(self._tokens_per_request): + yield payload + + +class Caller(Blackhole): + def __init__( + self, + downstream: Union[ActorHandle, DeploymentHandle], + *, + mode: IOMode, + tokens_per_request: int, + batch_size: int, + num_trials: int, + trial_runtime: float, + ): + self._h = downstream + self._mode = mode + self._tokens_per_request = tokens_per_request + self._batch_size = batch_size + self._num_trials = num_trials + self._trial_runtime = trial_runtime + self._durations = [] + + # Switch off logging to minimize its impact + logging.getLogger("ray").setLevel(logging.WARNING) + logging.getLogger("ray.serve").setLevel(logging.WARNING) + + def _get_remote_method(self): + if self._mode == IOMode.SYNC: + return self._h.stream + elif self._mode == IOMode.ASYNC: + return self._h.aio_stream + else: + raise NotImplementedError(f"Streaming mode not supported ({self._mode})") + + @abc.abstractmethod + async def _consume_single_stream(self): + pass + + async def _do_single_batch(self): + durations = await asyncio.gather( + *[ + self._execute(self._consume_single_stream) + for _ in range(self._batch_size) + ] + ) + + self._durations.extend(durations) + + async def _execute(self, fn): + start = time.monotonic() + await fn() + dur_s = time.monotonic() - start + return dur_s * 1000 # ms + + async def run_benchmark(self) -> Tuple[float, float]: + coro = run_throughput_benchmark( + fn=self._do_single_batch, + multiplier=self._batch_size * self._tokens_per_request, + num_trials=self._num_trials, + trial_runtime=self._trial_runtime, + ) + # total_runtime = await collect_profile_events(coro) + total_runtime = await coro + + p50, p75, p99 = np.percentile(self._durations, [50, 75, 99]) + + print(f"Individual request quantiles:\n\tP50={p50}\n\tP75={p75}\n\tP99={p99}") + + return total_runtime diff --git a/lib/python3.12/site-packages/ray/serve/_private/benchmarks/streaming/streaming_core_throughput.py b/lib/python3.12/site-packages/ray/serve/_private/benchmarks/streaming/streaming_core_throughput.py new file mode 100644 index 0000000000000000000000000000000000000000..4447d9996caab67918c8760964e6d5e8ee9de0c1 --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/_private/benchmarks/streaming/streaming_core_throughput.py @@ -0,0 +1,95 @@ +import click + +import ray +from ray.serve._private.benchmarks.streaming.common import Caller, Endpoint, IOMode + + +# @ray.remote(runtime_env=GRPC_DEBUG_RUNTIME_ENV) +@ray.remote +class EndpointActor(Endpoint): + pass + + +# @ray.remote(runtime_env=GRPC_DEBUG_RUNTIME_ENV) +@ray.remote +class CallerActor(Caller): + async def _consume_single_stream(self): + method = self._get_remote_method() + async for ref in method.options(num_returns="streaming").remote(): + r = ray.get(ref) + + # self.sink(str(r, 'utf-8')) + self.sink(r) + + +@click.command(help="Benchmark streaming deployment handle throughput.") +@click.option( + "--tokens-per-request", + type=int, + default=1000, + help="Number of tokens (per request) to stream from downstream deployment", +) +@click.option( + "--batch-size", + type=int, + default=10, + help="Number of requests to send to downstream deployment in each batch.", +) +@click.option( + "--num-replicas", + type=int, + default=1, + help="Number of replicas in the downstream deployment.", +) +@click.option( + "--num-trials", + type=int, + default=5, + help="Number of trials of the benchmark to run.", +) +@click.option( + "--trial-runtime", + type=int, + default=5, + help="Duration to run each trial of the benchmark for (seconds).", +) +@click.option( + "--io-mode", + type=str, + default="async", + help="Controls mode of the streaming generation (either 'sync' or 'async')", +) +def main( + tokens_per_request: int, + batch_size: int, + num_replicas: int, + num_trials: int, + trial_runtime: float, + io_mode: str, +): + h = CallerActor.remote( + EndpointActor.remote( + tokens_per_request=tokens_per_request, + ), + mode=IOMode(io_mode.upper()), + tokens_per_request=tokens_per_request, + batch_size=batch_size, + num_trials=num_trials, + trial_runtime=trial_runtime, + ) + + mean, stddev = ray.get(h.run_benchmark.remote()) + print( + "Core Actors streaming throughput ({}) {}: {} +- {} tokens/s".format( + io_mode.upper(), + f"(num_replicas={num_replicas}, " + f"tokens_per_request={tokens_per_request}, " + f"batch_size={batch_size})", + mean, + stddev, + ) + ) + + +if __name__ == "__main__": + main() diff --git a/lib/python3.12/site-packages/ray/serve/_private/benchmarks/streaming/streaming_grpc_throughput.py b/lib/python3.12/site-packages/ray/serve/_private/benchmarks/streaming/streaming_grpc_throughput.py new file mode 100644 index 0000000000000000000000000000000000000000..5d6e264437cd939ed6139d2a7f126c090a825f8f --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/_private/benchmarks/streaming/streaming_grpc_throughput.py @@ -0,0 +1,218 @@ +import asyncio +import logging +import time +from concurrent import futures +from tempfile import TemporaryDirectory + +import click +import grpc + +import ray +from ray.serve._private.benchmarks.streaming._grpc import ( + test_server_pb2, + test_server_pb2_grpc, +) +from ray.serve._private.benchmarks.streaming._grpc.grpc_server import TestGRPCServer +from ray.serve._private.benchmarks.streaming.common import Caller, IOMode + + +# @ray.remote(runtime_env=GRPC_DEBUG_RUNTIME_ENV) +@ray.remote +class EndpointActor: + async def __init__(self, tokens_per_request, socket_type, tempdir): + # Switch off logging to minimize its impact + logging.getLogger("ray").setLevel(logging.WARNING) + logging.getLogger("ray.serve").setLevel(logging.WARNING) + + self.server = await self.start_server(tokens_per_request, socket_type, tempdir) + + print("gRPC server started!") + + @staticmethod + async def start_server(tokens_per_request, socket_type, tempdir): + server = grpc.aio.server(futures.ThreadPoolExecutor(max_workers=1)) + + addr, server_creds, _ = _gen_addr_creds(socket_type, tempdir) + + server.add_secure_port(addr, server_creds) + + await server.start() + + test_server_pb2_grpc.add_GRPCTestServerServicer_to_server( + TestGRPCServer(tokens_per_request), server + ) + + return server + + +# @ray.remote(runtime_env=GRPC_DEBUG_RUNTIME_ENV) +@ray.remote +class GrpcCallerActor(Caller): + def __init__( + self, + tempdir, + socket_type, + *, + mode: IOMode, + tokens_per_request: int, + batch_size: int, + num_trials: int, + trial_runtime: float, + ): + super().__init__( + self.create_downstream(socket_type, tempdir), + mode=mode, + tokens_per_request=tokens_per_request, + batch_size=batch_size, + num_trials=num_trials, + trial_runtime=trial_runtime, + ) + + @staticmethod + def create_downstream(socket_type, tempdir): + addr, _, channel_creds = _gen_addr_creds(socket_type, tempdir) + + channel = grpc.aio.secure_channel( + addr, credentials=channel_creds, interceptors=[] + ) + + return test_server_pb2_grpc.GRPCTestServerStub(channel) + + async def _consume_single_stream(self): + try: + async for r in self._h.ServerStreaming(test_server_pb2.Request()): + self.sink(r) + except Exception as e: + print(str(e)) + + +def _gen_addr_creds(socket_type, tempdir): + if socket_type == "uds": + addr = f"unix://{tempdir}/server.sock" + server_creds = grpc.local_server_credentials(grpc.LocalConnectionType.UDS) + channel_creds = grpc.local_channel_credentials(grpc.LocalConnectionType.UDS) + elif socket_type == "local_tcp": + addr = "127.0.0.1:5432" + server_creds = grpc.local_server_credentials(grpc.LocalConnectionType.LOCAL_TCP) + channel_creds = grpc.local_channel_credentials( + grpc.LocalConnectionType.LOCAL_TCP + ) + else: + raise NotImplementedError(f"Not supported socket type ({socket_type})") + + return addr, server_creds, channel_creds + + +async def run_grpc_benchmark( + batch_size, + io_mode, + socket_type, + num_replicas, + num_trials, + tokens_per_request, + trial_runtime, +): + with TemporaryDirectory() as tempdir: + _ = EndpointActor.remote( + tokens_per_request=tokens_per_request, + socket_type=socket_type, + tempdir=tempdir, + ) + + ca = GrpcCallerActor.remote( + tempdir, + socket_type, + mode=IOMode(io_mode.upper()), + tokens_per_request=tokens_per_request, + batch_size=batch_size, + num_trials=num_trials, + trial_runtime=trial_runtime, + ) + + # TODO make starting server a method (to make synchronization explicit) + time.sleep(5) + + mean, stddev = await ca.run_benchmark.remote() + + print( + "gRPC streaming throughput ({}) {}: {} +- {} tokens/s".format( + io_mode.upper(), + f"(num_replicas={num_replicas}, " + f"tokens_per_request={tokens_per_request}, " + f"batch_size={batch_size})", + mean, + stddev, + ) + ) + + +@click.command(help="Benchmark streaming deployment handle throughput.") +@click.option( + "--tokens-per-request", + type=int, + default=1000, + help="Number of tokens (per request) to stream from downstream deployment", +) +@click.option( + "--batch-size", + type=int, + default=10, + help="Number of requests to send to downstream deployment in each batch.", +) +@click.option( + "--num-replicas", + type=int, + default=1, + help="Number of replicas in the downstream deployment.", +) +@click.option( + "--num-trials", + type=int, + default=5, + help="Number of trials of the benchmark to run.", +) +@click.option( + "--trial-runtime", + type=int, + default=5, + help="Duration to run each trial of the benchmark for (seconds).", +) +@click.option( + "--io-mode", + type=str, + default="async", + help="Controls mode of the streaming generation (either 'sync' or 'async')", +) +@click.option( + "--socket-type", + type=str, + default="local_tcp", + help="Controls type of socket used as underlying transport (either 'uds' or " + "'local_tcp')", +) +def main( + tokens_per_request: int, + batch_size: int, + num_replicas: int, + num_trials: int, + trial_runtime: float, + io_mode: str, + socket_type: grpc.LocalConnectionType, +): + """Reference benchmark for vanilla Python (w/ C-based core) gRPC implementation""" + + asyncio.run( + run_grpc_benchmark( + batch_size, + io_mode, + socket_type, + num_replicas, + num_trials, + tokens_per_request, + trial_runtime, + ) + ) + + +if __name__ == "__main__": + main() diff --git a/lib/python3.12/site-packages/ray/serve/_private/benchmarks/streaming/streaming_handle_throughput.py b/lib/python3.12/site-packages/ray/serve/_private/benchmarks/streaming/streaming_handle_throughput.py new file mode 100644 index 0000000000000000000000000000000000000000..77ed697053b3bdb5437aa28ee7aff24bf5a1e317 --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/_private/benchmarks/streaming/streaming_handle_throughput.py @@ -0,0 +1,94 @@ +import click + +from ray import serve +from ray.serve._private.benchmarks.streaming.common import Caller, Endpoint, IOMode + + +@serve.deployment(ray_actor_options={"num_cpus": 0}) +class EndpointDeployment(Endpoint): + pass + + +@serve.deployment +class CallerDeployment(Caller): + async def _consume_single_stream(self): + method = self._get_remote_method().options( + stream=True, + ) + + async for r in method.remote(): + # Blackhole the response + # self.sink(str(r, 'utf-8')) + self.sink(r) + + +@click.command(help="Benchmark streaming deployment handle throughput.") +@click.option( + "--tokens-per-request", + type=int, + default=1000, + help="Number of requests to send to downstream deployment in each trial.", +) +@click.option( + "--batch-size", + type=int, + default=10, + help="Number of requests to send to downstream deployment in each trial.", +) +@click.option( + "--num-replicas", + type=int, + default=1, + help="Number of replicas in the downstream deployment.", +) +@click.option( + "--num-trials", + type=int, + default=5, + help="Number of trials of the benchmark to run.", +) +@click.option( + "--trial-runtime", + type=int, + default=1, + help="Duration to run each trial of the benchmark for (seconds).", +) +@click.option( + "--io-mode", + type=str, + default="async", + help="Controls mode of the streaming generation (either 'sync' or 'async')", +) +def main( + tokens_per_request: int, + batch_size: int, + num_replicas: int, + num_trials: int, + trial_runtime: float, + io_mode: str, +): + app = CallerDeployment.bind( + EndpointDeployment.options(num_replicas=num_replicas).bind(tokens_per_request), + mode=IOMode(io_mode.upper()), + tokens_per_request=tokens_per_request, + batch_size=batch_size, + num_trials=num_trials, + trial_runtime=trial_runtime, + ) + h = serve.run(app) + + mean, stddev = h.run_benchmark.remote().result() + print( + "DeploymentHandle streaming throughput ({}) {}: {} +- {} tokens/s".format( + io_mode.upper(), + f"(num_replicas={num_replicas}, " + f"tokens_per_request={tokens_per_request}, " + f"batch_size={batch_size})", + mean, + stddev, + ) + ) + + +if __name__ == "__main__": + main() diff --git a/lib/python3.12/site-packages/ray/serve/_private/benchmarks/streaming/streaming_http_throughput.py b/lib/python3.12/site-packages/ray/serve/_private/benchmarks/streaming/streaming_http_throughput.py new file mode 100644 index 0000000000000000000000000000000000000000..b80b5e5b5dac41059362a1b13e762114efcd1717 --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/_private/benchmarks/streaming/streaming_http_throughput.py @@ -0,0 +1,140 @@ +import asyncio +import logging +from typing import Tuple + +import aiohttp +import click +from starlette.responses import StreamingResponse + +from ray import serve +from ray.serve._private.benchmarks.common import run_throughput_benchmark +from ray.serve.handle import DeploymentHandle + + +@serve.deployment(ray_actor_options={"num_cpus": 0}) +class Downstream: + def __init__(self, tokens_per_request: int): + logging.getLogger("ray.serve").setLevel(logging.WARNING) + + self._tokens_per_request = tokens_per_request + + async def stream(self): + for i in range(self._tokens_per_request): + yield "hi" + + def __call__(self, *args): + return StreamingResponse(self.stream()) + + +@serve.deployment(ray_actor_options={"num_cpus": 0}) +class Intermediate: + def __init__(self, downstream: DeploymentHandle): + logging.getLogger("ray.serve").setLevel(logging.WARNING) + + self._h = downstream.options(stream=True) + + async def stream(self): + async for token in self._h.stream.remote(): + yield token + + def __call__(self, *args): + return StreamingResponse(self.stream()) + + +async def _consume_single_stream(): + async with aiohttp.ClientSession(raise_for_status=True) as session: + async with session.get("http://localhost:8000") as r: + async for line in r.content: + pass + + +async def run_benchmark( + tokens_per_request: int, + batch_size: int, + num_trials: int, + trial_runtime: float, +) -> Tuple[float, float]: + async def _do_single_batch(): + await asyncio.gather(*[_consume_single_stream() for _ in range(batch_size)]) + + return await run_throughput_benchmark( + fn=_do_single_batch, + multiplier=batch_size * tokens_per_request, + num_trials=num_trials, + trial_runtime=trial_runtime, + ) + + +@click.command(help="Benchmark streaming HTTP throughput.") +@click.option( + "--tokens-per-request", + type=int, + default=1000, + help="Number of requests to send to downstream deployment in each trial.", +) +@click.option( + "--batch-size", + type=int, + default=10, + help="Number of requests to send to downstream deployment in each trial.", +) +@click.option( + "--num-replicas", + type=int, + default=1, + help="Number of replicas in the downstream deployment.", +) +@click.option( + "--num-trials", + type=int, + default=5, + help="Number of trials of the benchmark to run.", +) +@click.option( + "--trial-runtime", + type=int, + default=1, + help="Duration to run each trial of the benchmark for (seconds).", +) +@click.option( + "--use-intermediate-deployment", + is_flag=True, + default=False, + help="Whether to run an intermediate deployment proxying the requests.", +) +def main( + tokens_per_request: int, + batch_size: int, + num_replicas: int, + num_trials: int, + trial_runtime: float, + use_intermediate_deployment: bool, +): + app = Downstream.options(num_replicas=num_replicas).bind(tokens_per_request) + if use_intermediate_deployment: + app = Intermediate.bind(app) + + serve.run(app) + + mean, stddev = asyncio.new_event_loop().run_until_complete( + run_benchmark( + tokens_per_request, + batch_size, + num_trials, + trial_runtime, + ) + ) + print( + "HTTP streaming throughput {}: {} +- {} tokens/s".format( + f"(num_replicas={num_replicas}, " + f"tokens_per_request={tokens_per_request}, " + f"batch_size={batch_size}, " + f"use_intermediate_deployment={use_intermediate_deployment})", + mean, + stddev, + ) + ) + + +if __name__ == "__main__": + main() diff --git a/lib/python3.12/site-packages/ray/serve/_private/build_app.py b/lib/python3.12/site-packages/ray/serve/_private/build_app.py new file mode 100644 index 0000000000000000000000000000000000000000..b52cca21812b1330130d732cd70b8eaf7e7ca77a --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/_private/build_app.py @@ -0,0 +1,237 @@ +import logging +from copy import deepcopy +from dataclasses import dataclass +from typing import Any, Callable, Dict, Generic, List, Optional, TypeVar, Union + +from ray.dag.py_obj_scanner import _PyObjScanner +from ray.serve._private.constants import SERVE_LOGGER_NAME +from ray.serve.deployment import Application, Deployment +from ray.serve.handle import DeploymentHandle +from ray.serve.schema import LoggingConfig + +logger = logging.getLogger(SERVE_LOGGER_NAME) + +K = TypeVar("K") +V = TypeVar("V") + + +class IDDict(dict, Generic[K, V]): + """Dictionary that uses id() for keys instead of hash(). + + This is necessary because Application objects aren't hashable and we want each + instance to map to a unique key. + """ + + def __getitem__(self, key: K) -> V: + if not isinstance(key, int): + key = id(key) + return super().__getitem__(key) + + def __setitem__(self, key: K, value: V): + if not isinstance(key, int): + key = id(key) + return super().__setitem__(key, value) + + def __delitem__(self, key: K): + if not isinstance(key, int): + key = id(key) + return super().__delitem__(key) + + def __contains__(self, key: object): + if not isinstance(key, int): + key = id(key) + return super().__contains__(key) + + +@dataclass(frozen=True) +class BuiltApplication: + # Name of the application. + name: str + route_prefix: Optional[str] + logging_config: Optional[LoggingConfig] + # Name of the application's 'ingress' deployment + # (the one exposed over gRPC/HTTP/handle). + ingress_deployment_name: str + # List of unique deployments comprising the app. + deployments: List[Deployment] + # Dict[name, DeploymentHandle] mapping deployment names to the handles that replaced + # them in other deployments' init args/kwargs. + deployment_handles: Dict[str, DeploymentHandle] + + +def _make_deployment_handle_default( + deployment: Deployment, app_name: str +) -> DeploymentHandle: + return DeploymentHandle( + deployment.name, + app_name=app_name, + ) + + +def build_app( + app: Application, + *, + name: str, + route_prefix: Optional[str] = None, + logging_config: Optional[Union[Dict, LoggingConfig]] = None, + default_runtime_env: Optional[Dict[str, Any]] = None, + make_deployment_handle: Optional[ + Callable[[Deployment, str], DeploymentHandle] + ] = None, +) -> BuiltApplication: + """Builds the application into a list of finalized deployments. + + The following transformations are made: + - Application objects in constructor args/kwargs are converted to + DeploymentHandles for injection at runtime. + - Name conflicts from deployments that use the same class are handled + by appending a monotonically increasing suffix (e.g., SomeClass_1). + + Returns: BuiltApplication + """ + if make_deployment_handle is None: + make_deployment_handle = _make_deployment_handle_default + + handles = IDDict() + deployment_names = IDDict() + deployments = _build_app_recursive( + app, + app_name=name, + handles=handles, + deployment_names=deployment_names, + default_runtime_env=default_runtime_env, + make_deployment_handle=make_deployment_handle, + ) + return BuiltApplication( + name=name, + route_prefix=route_prefix, + logging_config=logging_config, + ingress_deployment_name=app._bound_deployment.name, + deployments=deployments, + deployment_handles={ + deployment_names[app]: handle for app, handle in handles.items() + }, + ) + + +def _build_app_recursive( + app: Application, + *, + app_name: str, + deployment_names: IDDict[Application, str], + handles: IDDict[Application, DeploymentHandle], + default_runtime_env: Optional[Dict[str, Any]] = None, + make_deployment_handle: Callable[[Deployment, str], DeploymentHandle], +) -> List[Deployment]: + """Recursively traverses the graph of Application objects. + + Each Application will have an associated DeploymentHandle created that will replace + it in any occurrences in other Applications' args or kwargs. + + Also collects a list of the unique Applications encountered and returns them as + deployable Deployment objects. + """ + # This application has already been encountered. + # There's no need to recurse into its child args and we don't want to create + # a duplicate entry for it in the list of deployments. + if app in handles: + return [] + + deployments = [] + scanner = _PyObjScanner(source_type=Application) + try: + # Recursively traverse any Application objects bound to init args/kwargs. + child_apps = scanner.find_nodes( + (app._bound_deployment.init_args, app._bound_deployment.init_kwargs) + ) + for child_app in child_apps: + deployments.extend( + _build_app_recursive( + child_app, + app_name=app_name, + handles=handles, + deployment_names=deployment_names, + make_deployment_handle=make_deployment_handle, + default_runtime_env=default_runtime_env, + ) + ) + + # Replace Application objects with their corresponding DeploymentHandles. + new_init_args, new_init_kwargs = scanner.replace_nodes(handles) + final_deployment = app._bound_deployment.options( + name=_get_unique_deployment_name_memoized(app, deployment_names), + _init_args=new_init_args, + _init_kwargs=new_init_kwargs, + ) + final_deployment = _set_default_runtime_env( + final_deployment, default_runtime_env + ) + + # Create the DeploymentHandle that will be used to replace this application + # in the arguments of its parent(s). + handles[app] = make_deployment_handle( + final_deployment, + app_name, + ) + + return deployments + [final_deployment] + finally: + scanner.clear() + + +def _set_default_runtime_env( + d: Deployment, default_runtime_env: Optional[Dict[str, Any]] +) -> Deployment: + """Configures the deployment with the provided default runtime_env. + + If the deployment does not have a runtime_env configured, the default will be set. + + If it does have a runtime_env configured but that runtime_env does not have a + working_dir, only the working_dir field will be set. + + Else the deployment's runtime_env will be left untouched. + """ + if not default_runtime_env: + return d + + ray_actor_options = deepcopy(d.ray_actor_options or {}) + default_working_dir = default_runtime_env.get("working_dir", None) + if "runtime_env" not in ray_actor_options: + ray_actor_options["runtime_env"] = default_runtime_env + elif default_working_dir is not None: + ray_actor_options["runtime_env"].setdefault("working_dir", default_working_dir) + + return d.options(ray_actor_options=ray_actor_options) + + +def _get_unique_deployment_name_memoized( + app: Application, deployment_names: IDDict[Application, str] +) -> str: + """Generates a name for the deployment. + + This is used to handle collisions when the user does not specify a name + explicitly, so typically we'd use the class name as the default. + + In that case, we append a monotonically increasing suffix to the name, e.g., + Deployment, then Deployment_1, then Deployment_2, ... + + Names are memoized in the `deployment_names` dict, which should be passed to + subsequent calls to this function. + """ + if app in deployment_names: + return deployment_names[app] + + idx = 1 + name = app._bound_deployment.name + while name in deployment_names.values(): + name = f"{app._bound_deployment.name}_{idx}" + idx += 1 + + if idx != 1: + logger.warning( + "There are multiple deployments with the same name " + f"'{app._bound_deployment.name}'. Renaming one to '{name}'." + ) + + deployment_names[app] = name + return name diff --git a/lib/python3.12/site-packages/ray/serve/_private/client.py b/lib/python3.12/site-packages/ray/serve/_private/client.py new file mode 100644 index 0000000000000000000000000000000000000000..4ad027fe8160b4bfb744beb910eaad389f6c3aaa --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/_private/client.py @@ -0,0 +1,616 @@ +import asyncio +import inspect +import logging +import random +import time +from collections.abc import Sequence +from functools import wraps +from typing import Callable, Dict, List, Optional, Tuple, Union + +import ray +from ray.actor import ActorHandle +from ray.serve._private.application_state import StatusOverview +from ray.serve._private.build_app import BuiltApplication +from ray.serve._private.common import ( + DeploymentID, + DeploymentStatus, + DeploymentStatusInfo, + RequestRoutingInfo, +) +from ray.serve._private.constants import ( + CLIENT_CHECK_CREATION_POLLING_INTERVAL_S, + CLIENT_POLLING_INTERVAL_S, + HTTP_PROXY_TIMEOUT, + MAX_CACHED_HANDLES, + SERVE_DEFAULT_APP_NAME, + SERVE_LOGGER_NAME, +) +from ray.serve._private.controller import ServeController +from ray.serve._private.deploy_utils import get_deploy_args +from ray.serve._private.deployment_info import DeploymentInfo +from ray.serve._private.http_util import ASGIAppReplicaWrapper +from ray.serve._private.utils import get_random_string +from ray.serve.config import HTTPOptions +from ray.serve.exceptions import RayServeException +from ray.serve.generated.serve_pb2 import ( + DeploymentArgs, + DeploymentRoute, + DeploymentStatusInfo as DeploymentStatusInfoProto, + StatusOverview as StatusOverviewProto, +) +from ray.serve.handle import DeploymentHandle +from ray.serve.schema import ( + ApplicationStatus, + LoggingConfig, + ServeApplicationSchema, + ServeDeploySchema, +) + +logger = logging.getLogger(SERVE_LOGGER_NAME) + + +def _ensure_connected(f: Callable) -> Callable: + @wraps(f) + def check(self, *args, **kwargs): + if self._shutdown: + raise RayServeException("Client has already been shut down.") + return f(self, *args, **kwargs) + + return check + + +class ServeControllerClient: + def __init__( + self, + controller: ActorHandle, + ): + self._controller: ServeController = controller + self._shutdown = False + self._http_config: HTTPOptions = ray.get(controller.get_http_config.remote()) + self._root_url = ray.get(controller.get_root_url.remote()) + + # Each handle has the overhead of long poll client, therefore cached. + self.handle_cache = dict() + self._evicted_handle_keys = set() + + @property + def root_url(self): + return self._root_url + + @property + def http_config(self): + return self._http_config + + def __reduce__(self): + raise RayServeException(("Ray Serve client cannot be serialized.")) + + def shutdown_cached_handles(self): + """Shuts down all cached handles. + + Remove the reference to the cached handles so that they can be + garbage collected. + """ + for cache_key in list(self.handle_cache): + self.handle_cache[cache_key].shutdown() + del self.handle_cache[cache_key] + + async def shutdown_cached_handles_async(self): + """Shuts down all cached handles asynchronously. + + Remove the reference to the cached handles so that they can be + garbage collected. + """ + + async def shutdown_task(cache_key): + await self.handle_cache[cache_key].shutdown_async() + del self.handle_cache[cache_key] + + await asyncio.gather( + *[shutdown_task(cache_key) for cache_key in list(self.handle_cache)] + ) + + def shutdown(self, timeout_s: float = 30.0) -> None: + """Completely shut down the connected Serve instance. + + Shuts down all processes and deletes all state associated with the + instance. + """ + self.shutdown_cached_handles() + + if ray.is_initialized() and not self._shutdown: + try: + ray.get(self._controller.graceful_shutdown.remote(), timeout=timeout_s) + except ray.exceptions.RayActorError: + # Controller has been shut down. + pass + except TimeoutError: + logger.warning( + f"Controller failed to shut down within {timeout_s}s. " + "Check controller logs for more details." + ) + self._shutdown = True + + async def shutdown_async(self, timeout_s: float = 30.0) -> None: + """Completely shut down the connected Serve instance. + + Shuts down all processes and deletes all state associated with the + instance. + """ + await self.shutdown_cached_handles_async() + + if ray.is_initialized() and not self._shutdown: + try: + await asyncio.wait_for( + self._controller.graceful_shutdown.remote(), timeout=timeout_s + ) + except ray.exceptions.RayActorError: + # Controller has been shut down. + pass + except TimeoutError: + logger.warning( + f"Controller failed to shut down within {timeout_s}s. " + "Check controller logs for more details." + ) + self._shutdown = True + + def _wait_for_deployment_healthy(self, name: str, timeout_s: int = -1): + """Waits for the named deployment to enter "HEALTHY" status. + + Raises RuntimeError if the deployment enters the "UNHEALTHY" status + instead. + + Raises TimeoutError if this doesn't happen before timeout_s. + """ + start = time.time() + while time.time() - start < timeout_s or timeout_s < 0: + status_bytes = ray.get(self._controller.get_deployment_status.remote(name)) + + if status_bytes is None: + raise RuntimeError( + f"Waiting for deployment {name} to be HEALTHY, " + "but deployment doesn't exist." + ) + + status = DeploymentStatusInfo.from_proto( + DeploymentStatusInfoProto.FromString(status_bytes) + ) + + if status.status == DeploymentStatus.HEALTHY: + break + elif status.status == DeploymentStatus.UNHEALTHY: + raise RuntimeError( + f"Deployment {name} is UNHEALTHY: " f"{status.message}" + ) + else: + # Guard against new unhandled statuses being added. + assert status.status == DeploymentStatus.UPDATING + + logger.debug( + f"Waiting for {name} to be healthy, current status: " + f"{status.status}." + ) + time.sleep(CLIENT_POLLING_INTERVAL_S) + else: + raise TimeoutError( + f"Deployment {name} did not become HEALTHY after {timeout_s}s." + ) + + def _wait_for_deployment_deleted( + self, name: str, app_name: str, timeout_s: int = 60 + ): + """Waits for the named deployment to be shut down and deleted. + + Raises TimeoutError if this doesn't happen before timeout_s. + """ + start = time.time() + while time.time() - start < timeout_s: + curr_status_bytes = ray.get( + self._controller.get_deployment_status.remote(name) + ) + if curr_status_bytes is None: + break + curr_status = DeploymentStatusInfo.from_proto( + DeploymentStatusInfoProto.FromString(curr_status_bytes) + ) + logger.debug( + f"Waiting for {name} to be deleted, current status: {curr_status}." + ) + time.sleep(CLIENT_POLLING_INTERVAL_S) + else: + raise TimeoutError(f"Deployment {name} wasn't deleted after {timeout_s}s.") + + def _wait_for_deployment_created( + self, deployment_name: str, app_name: str, timeout_s: int = -1 + ): + """Waits for the named deployment to be created. + + A deployment being created simply means that its been registered + with the deployment state manager. The deployment state manager + will then continue to reconcile the deployment towards its + target state. + + Raises TimeoutError if this doesn't happen before timeout_s. + """ + + start = time.time() + while time.time() - start < timeout_s or timeout_s < 0: + status_bytes = ray.get( + self._controller.get_deployment_status.remote(deployment_name, app_name) + ) + + if status_bytes is not None: + break + + logger.debug( + f"Waiting for deployment '{deployment_name}' in application " + f"'{app_name}' to be created." + ) + time.sleep(CLIENT_CHECK_CREATION_POLLING_INTERVAL_S) + else: + raise TimeoutError( + f"Deployment '{deployment_name}' in application '{app_name}' " + f"did not become HEALTHY after {timeout_s}s." + ) + + def _wait_for_application_running(self, name: str, timeout_s: int = -1): + """Waits for the named application to enter "RUNNING" status. + + Raises: + RuntimeError: if the application enters the "DEPLOY_FAILED" status instead. + TimeoutError: if this doesn't happen before timeout_s. + """ + start = time.time() + while time.time() - start < timeout_s or timeout_s < 0: + status_bytes = ray.get(self._controller.get_serve_status.remote(name)) + if status_bytes is None: + raise RuntimeError( + f"Waiting for application {name} to be RUNNING, " + "but application doesn't exist." + ) + + status = StatusOverview.from_proto( + StatusOverviewProto.FromString(status_bytes) + ) + + if status.app_status.status == ApplicationStatus.RUNNING: + break + elif status.app_status.status == ApplicationStatus.DEPLOY_FAILED: + raise RuntimeError( + f"Deploying application {name} failed: {status.app_status.message}" + ) + + logger.debug( + f"Waiting for {name} to be RUNNING, current status: " + f"{status.app_status.status}." + ) + time.sleep(CLIENT_POLLING_INTERVAL_S) + else: + raise TimeoutError( + f"Application {name} did not become RUNNING after {timeout_s}s." + ) + + @_ensure_connected + def wait_for_proxies_serving( + self, wait_for_applications_running: bool = True + ) -> None: + """Wait for the proxies to be ready to serve requests.""" + proxy_handles = ray.get(self._controller.get_proxies.remote()) + serving_refs = [ + handle.serving.remote( + wait_for_applications_running=wait_for_applications_running + ) + for handle in proxy_handles.values() + ] + + done, pending = ray.wait( + serving_refs, + timeout=HTTP_PROXY_TIMEOUT, + num_returns=len(serving_refs), + ) + + if len(pending) > 0: + raise TimeoutError(f"Proxies not available after {HTTP_PROXY_TIMEOUT}s.") + + # Ensure the proxies are either serving or dead. + for ref in done: + try: + ray.get(ref, timeout=1) + except ray.exceptions.RayActorError: + pass + except Exception: + raise TimeoutError( + f"Proxies not available after {HTTP_PROXY_TIMEOUT}s." + ) + + @_ensure_connected + def deploy_applications( + self, + built_apps: Sequence[BuiltApplication], + *, + wait_for_ingress_deployment_creation: bool = True, + wait_for_applications_running: bool = True, + ) -> List[DeploymentHandle]: + name_to_deployment_args_list = {} + for app in built_apps: + deployment_args_list = [] + for deployment in app.deployments: + if deployment.logging_config is None and app.logging_config: + deployment = deployment.options(logging_config=app.logging_config) + + is_ingress = deployment.name == app.ingress_deployment_name + deployment_args = get_deploy_args( + deployment.name, + ingress=is_ingress, + replica_config=deployment._replica_config, + deployment_config=deployment._deployment_config, + version=deployment._version or get_random_string(), + route_prefix=app.route_prefix if is_ingress else None, + ) + + deployment_args_proto = DeploymentArgs() + deployment_args_proto.deployment_name = deployment_args[ + "deployment_name" + ] + deployment_args_proto.deployment_config = deployment_args[ + "deployment_config_proto_bytes" + ] + deployment_args_proto.replica_config = deployment_args[ + "replica_config_proto_bytes" + ] + deployment_args_proto.deployer_job_id = deployment_args[ + "deployer_job_id" + ] + if deployment_args["route_prefix"]: + deployment_args_proto.route_prefix = deployment_args["route_prefix"] + deployment_args_proto.ingress = deployment_args["ingress"] + + deployment_args_list.append(deployment_args_proto.SerializeToString()) + + name_to_deployment_args_list[app.name] = deployment_args_list + + # Validate applications before sending to controller + self._check_ingress_deployments(built_apps) + + ray.get( + self._controller.deploy_applications.remote(name_to_deployment_args_list) + ) + + handles = [] + for app in built_apps: + # The deployment state is not guaranteed to be created after + # deploy_application returns; the application state manager will + # need another reconcile iteration to create it. + if wait_for_ingress_deployment_creation: + self._wait_for_deployment_created(app.ingress_deployment_name, app.name) + + if wait_for_applications_running: + self._wait_for_application_running(app.name) + if app.route_prefix is not None: + url_part = " at " + self._root_url + app.route_prefix + else: + url_part = "" + logger.info(f"Application '{app.name}' is ready{url_part}.") + + handles.append( + self.get_handle( + app.ingress_deployment_name, app.name, check_exists=False + ) + ) + + return handles + + @_ensure_connected + def deploy_apps( + self, + config: Union[ServeApplicationSchema, ServeDeploySchema], + _blocking: bool = False, + ) -> None: + """Starts a task on the controller that deploys application(s) from a config. + + Args: + config: A single-application config (ServeApplicationSchema) or a + multi-application config (ServeDeploySchema) + _blocking: Whether to block until the application is running. + + Raises: + RayTaskError: If the deploy task on the controller fails. This can be + because a single-app config was deployed after deploying a multi-app + config, or vice versa. + """ + ray.get(self._controller.apply_config.remote(config)) + + if _blocking: + timeout_s = 60 + + if isinstance(config, ServeDeploySchema): + app_names = {app.name for app in config.applications} + else: + app_names = {config.name} + + start = time.time() + while time.time() - start < timeout_s: + statuses = self.list_serve_statuses() + app_to_status = { + status.name: status.app_status.status + for status in statuses + if status.name in app_names + } + if len(app_names) == len(app_to_status) and set( + app_to_status.values() + ) == {ApplicationStatus.RUNNING}: + break + + time.sleep(CLIENT_POLLING_INTERVAL_S) + else: + raise TimeoutError( + f"Serve application isn't running after {timeout_s}s." + ) + + self.wait_for_proxies_serving(wait_for_applications_running=True) + + def _check_ingress_deployments( + self, built_apps: Sequence[BuiltApplication] + ) -> None: + """Check @serve.ingress of deployments across applications. + + Raises: RayServeException if more than one @serve.ingress + is found among deployments in any single application. + """ + for app in built_apps: + num_ingress_deployments = 0 + for deployment in app.deployments: + if inspect.isclass(deployment.func_or_class) and issubclass( + deployment.func_or_class, ASGIAppReplicaWrapper + ): + num_ingress_deployments += 1 + + if num_ingress_deployments > 1: + raise RayServeException( + f'Found multiple FastAPI deployments in application "{app.name}".' + "Please only include one deployment with @serve.ingress " + "in your application to avoid this issue." + ) + + @_ensure_connected + def delete_apps(self, names: List[str], blocking: bool = True): + if not names: + return + + logger.info(f"Deleting app {names}") + self._controller.delete_apps.remote(names) + if blocking: + start = time.time() + while time.time() - start < 60: + curr_statuses_bytes = ray.get( + self._controller.get_serve_statuses.remote(names) + ) + all_deleted = True + for cur_status_bytes in curr_statuses_bytes: + cur_status = StatusOverview.from_proto( + StatusOverviewProto.FromString(cur_status_bytes) + ) + if cur_status.app_status.status != ApplicationStatus.NOT_STARTED: + all_deleted = False + if all_deleted: + return + time.sleep(CLIENT_POLLING_INTERVAL_S) + else: + raise TimeoutError( + f"Some of these applications weren't deleted after 60s: {names}" + ) + + @_ensure_connected + def delete_all_apps(self, blocking: bool = True): + """Delete all applications""" + all_apps = [] + for status_bytes in ray.get(self._controller.list_serve_statuses.remote()): + proto = StatusOverviewProto.FromString(status_bytes) + status = StatusOverview.from_proto(proto) + all_apps.append(status.name) + self.delete_apps(all_apps, blocking) + + @_ensure_connected + def get_deployment_info( + self, name: str, app_name: str + ) -> Tuple[DeploymentInfo, str]: + deployment_route = DeploymentRoute.FromString( + ray.get(self._controller.get_deployment_info.remote(name, app_name)) + ) + return ( + DeploymentInfo.from_proto(deployment_route.deployment_info), + deployment_route.route if deployment_route.route != "" else None, + ) + + @_ensure_connected + def get_serve_status(self, name: str = SERVE_DEFAULT_APP_NAME) -> StatusOverview: + proto = StatusOverviewProto.FromString( + ray.get(self._controller.get_serve_status.remote(name)) + ) + return StatusOverview.from_proto(proto) + + @_ensure_connected + def list_serve_statuses(self) -> List[StatusOverview]: + statuses_bytes = ray.get(self._controller.list_serve_statuses.remote()) + return [ + StatusOverview.from_proto(StatusOverviewProto.FromString(status_bytes)) + for status_bytes in statuses_bytes + ] + + @_ensure_connected + def get_all_deployment_statuses(self) -> List[DeploymentStatusInfo]: + statuses_bytes = ray.get(self._controller.get_all_deployment_statuses.remote()) + return [ + DeploymentStatusInfo.from_proto( + DeploymentStatusInfoProto.FromString(status_bytes) + ) + for status_bytes in statuses_bytes + ] + + @_ensure_connected + def get_serve_details(self) -> Dict: + return ray.get(self._controller.get_serve_instance_details.remote()) + + @_ensure_connected + def get_handle( + self, + deployment_name: str, + app_name: Optional[str] = SERVE_DEFAULT_APP_NAME, + check_exists: bool = True, + ) -> DeploymentHandle: + """Construct a handle for the specified deployment. + + Args: + deployment_name: Deployment name. + app_name: Application name. + check_exists: If False, then Serve won't check the deployment + is registered. True by default. + + Returns: + DeploymentHandle + """ + deployment_id = DeploymentID(name=deployment_name, app_name=app_name) + cache_key = (deployment_name, app_name, check_exists) + if cache_key in self.handle_cache: + return self.handle_cache[cache_key] + + if check_exists: + all_deployments = ray.get(self._controller.list_deployment_ids.remote()) + if deployment_id not in all_deployments: + raise KeyError(f"{deployment_id} does not exist.") + + handle = DeploymentHandle(deployment_name, app_name) + self.handle_cache[cache_key] = handle + if cache_key in self._evicted_handle_keys: + logger.warning( + "You just got a ServeHandle that was evicted from internal " + "cache. This means you are getting too many ServeHandles in " + "the same process, this will bring down Serve's performance. " + "Please post a github issue at " + "https://github.com/ray-project/ray/issues to let the Serve " + "team to find workaround for your use case." + ) + + if len(self.handle_cache) > MAX_CACHED_HANDLES: + # Perform random eviction to keep the handle cache from growing + # infinitely. We used use WeakValueDictionary but hit + # https://github.com/ray-project/ray/issues/18980. + evict_key = random.choice(list(self.handle_cache.keys())) + self._evicted_handle_keys.add(evict_key) + self.handle_cache.pop(evict_key) + + return handle + + @_ensure_connected + def record_request_routing_info(self, info: RequestRoutingInfo): + """Record replica routing information for a replica. + + Args: + info: RequestRoutingInfo including deployment name, replica tag, + multiplex model ids, and routing stats. + """ + self._controller.record_request_routing_info.remote(info) + + @_ensure_connected + def update_global_logging_config(self, logging_config: LoggingConfig): + """Reconfigure the logging config for the controller & proxies.""" + self._controller.reconfigure_global_logging_config.remote(logging_config) diff --git a/lib/python3.12/site-packages/ray/serve/_private/cluster_node_info_cache.py b/lib/python3.12/site-packages/ray/serve/_private/cluster_node_info_cache.py new file mode 100644 index 0000000000000000000000000000000000000000..ae0121dd309d61d49409a6801650cd91a1f7f269 --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/_private/cluster_node_info_cache.py @@ -0,0 +1,102 @@ +from abc import ABC, abstractmethod +from typing import Dict, List, Optional, Set, Tuple, Union + +import ray +from ray._raylet import GcsClient +from ray.serve._private.constants import RAY_GCS_RPC_TIMEOUT_S + + +class ClusterNodeInfoCache(ABC): + """Provide access to cached node information in the cluster.""" + + def __init__(self, gcs_client: GcsClient): + self._gcs_client = gcs_client + self._cached_alive_nodes = None + self._cached_node_labels = dict() + self._cached_total_resources_per_node = dict() + self._cached_available_resources_per_node = dict() + + def update(self): + """Update the cache by fetching latest node information from GCS. + + This should be called once in each update cycle. + Within an update cycle, everyone will see the same + cached node info avoiding any potential issues + caused by inconsistent node info seen by different components. + """ + nodes = self._gcs_client.get_all_node_info(timeout=RAY_GCS_RPC_TIMEOUT_S) + alive_nodes = [ + (node_id.hex(), node.node_name, node.instance_id) + for (node_id, node) in nodes.items() + if node.state == ray.core.generated.gcs_pb2.GcsNodeInfo.ALIVE + ] + + # Sort on NodeID to ensure the ordering is deterministic across the cluster. + sorted(alive_nodes) + self._cached_alive_nodes = alive_nodes + self._cached_node_labels = { + node_id.hex(): dict(node.labels) for (node_id, node) in nodes.items() + } + + # Node resources + self._cached_total_resources_per_node = { + node_id.hex(): dict(node.resources_total) + for (node_id, node) in nodes.items() + } + + self._cached_available_resources_per_node = ( + ray._private.state.available_resources_per_node() + ) + + def get_alive_nodes(self) -> List[Tuple[str, str, str]]: + """Get IDs, IPs, and Instance IDs for all live nodes in the cluster. + + Returns a list of (node_id: str, node_ip: str, instance_id: str). + The node_id can be passed into the Ray SchedulingPolicy API. + """ + return self._cached_alive_nodes + + def get_total_resources_per_node(self) -> Dict[str, Dict]: + """Get total resources for alive nodes.""" + return self._cached_total_resources_per_node + + def get_alive_node_ids(self) -> Set[str]: + """Get IDs of all live nodes in the cluster.""" + return {node_id for node_id, _, _ in self.get_alive_nodes()} + + @abstractmethod + def get_draining_nodes(self) -> Dict[str, int]: + """Get draining nodes in the cluster and their deadlines.""" + raise NotImplementedError + + @abstractmethod + def get_node_az(self, node_id: str) -> Optional[str]: + """Get availability zone of a node.""" + raise NotImplementedError + + def get_active_node_ids(self) -> Set[str]: + """Get IDs of all active nodes in the cluster. + + A node is active if it's schedulable for new tasks and actors. + """ + return self.get_alive_node_ids() - set(self.get_draining_nodes()) + + def get_available_resources_per_node(self) -> Dict[str, Union[float, Dict]]: + """Get available resources per node. + + Returns a map from (node_id -> Dict of resources). + """ + + return self._cached_available_resources_per_node + + +class DefaultClusterNodeInfoCache(ClusterNodeInfoCache): + def __init__(self, gcs_client: GcsClient): + super().__init__(gcs_client) + + def get_draining_nodes(self) -> Dict[str, int]: + return dict() + + def get_node_az(self, node_id: str) -> Optional[str]: + """Get availability zone of a node.""" + return None diff --git a/lib/python3.12/site-packages/ray/serve/_private/common.py b/lib/python3.12/site-packages/ray/serve/_private/common.py new file mode 100644 index 0000000000000000000000000000000000000000..0d73a5cba64c21139f86140d0be98dc8e7a283d6 --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/_private/common.py @@ -0,0 +1,883 @@ +import json +from dataclasses import asdict, dataclass, field +from enum import Enum +from typing import Any, Awaitable, Callable, Dict, List, Optional + +from starlette.types import Scope + +import ray +from ray.actor import ActorHandle +from ray.serve._private.constants import SERVE_DEFAULT_APP_NAME, SERVE_NAMESPACE +from ray.serve.generated.serve_pb2 import ( + DeploymentStatus as DeploymentStatusProto, + DeploymentStatusInfo as DeploymentStatusInfoProto, + DeploymentStatusTrigger as DeploymentStatusTriggerProto, +) +from ray.serve.grpc_util import RayServegRPCContext +from ray.util.annotations import PublicAPI + +REPLICA_ID_FULL_ID_STR_PREFIX = "SERVE_REPLICA::" + + +@dataclass(frozen=True) +class DeploymentID: + name: str + app_name: str = SERVE_DEFAULT_APP_NAME + + def to_replica_actor_class_name(self): + return f"ServeReplica:{self.app_name}:{self.name}" + + def __str__(self): + return f"Deployment(name='{self.name}', app='{self.app_name}')" + + def __repr__(self): + return str(self) + + +@PublicAPI(stability="alpha") +@dataclass(frozen=True) +class ReplicaID: + """A unique identifier for a replica.""" + + unique_id: str + """A unique identifier for the replica within the deployment.""" + + deployment_id: DeploymentID + """The deployment this replica belongs to.""" + + def to_full_id_str(self) -> str: + s = f"{self.deployment_id.name}#{self.unique_id}" + if self.deployment_id.app_name: + s = f"{self.deployment_id.app_name}#{s}" + + return f"{REPLICA_ID_FULL_ID_STR_PREFIX}{s}" + + @staticmethod + def is_full_id_str(s: str) -> bool: + return s.startswith(REPLICA_ID_FULL_ID_STR_PREFIX) + + @classmethod + def from_full_id_str(cls, s: str): + assert cls.is_full_id_str(s) + + parsed = s[len(REPLICA_ID_FULL_ID_STR_PREFIX) :].split("#") + if len(parsed) == 3: + app_name, deployment_name, unique_id = parsed + elif len(parsed) == 2: + app_name = "" + deployment_name, unique_id = parsed + else: + raise ValueError( + f"Given replica ID string {s} didn't match expected pattern, " + "ensure it has either two or three fields with delimiter '#'." + ) + + return cls( + unique_id, + deployment_id=DeploymentID(name=deployment_name, app_name=app_name), + ) + + def __repr__(self) -> str: + return str(self) + + def __str__(self) -> str: + """Returns a human-readable string. + + This is used in user-facing log messages, so take care when updating it. + """ + return ( + f"Replica(" + f"id='{self.unique_id}', " + f"deployment='{self.deployment_id.name}', " + f"app='{self.deployment_id.app_name}'" + ")" + ) + + +NodeId = str +Duration = float +ApplicationName = str + + +@dataclass +class EndpointInfo: + """Metadata about a deployment's HTTP/gRPC endpoint. + + This represents the public routing interface for a deployment. It's created when + a deployment is registered with a route prefix and broadcast to all proxies via + the long poll mechanism (ROUTE_TABLE namespace). + + Flow: + 1. Created in ApplicationState when deployment is applied + 2. Stored in EndpointState (controller's source of truth) + 3. Broadcast to all ProxyActors via long poll (ROUTE_TABLE) + 4. Cached in ProxyRouter for request routing + 5. Used to route incoming HTTP/gRPC requests to correct deployments + 6. Used to determine route patterns for accurate metrics tagging + + Key Difference from DeploymentInfo: + - EndpointInfo: Just HTTP/gRPC routing metadata (shared with proxies) + - DeploymentInfo: Complete deployment config (replicas, resources, etc.) + + Attributes: + route: The route prefix for this deployment (e.g., "/api"). + app_is_cross_language: Whether the deployment uses a different language + than the proxy (e.g., Java deployment with Python proxy). This affects + how the proxy serializes/deserializes requests. + route_patterns: List of all ASGI route patterns for this deployment + (e.g., ["/", "/users/{user_id}", "/items/{item_id}/details"]). + Used by proxies to match incoming requests to specific route patterns + for accurate metrics tagging. This avoids high cardinality by using + parameterized patterns instead of individual request paths. + Only populated for deployments with ASGI apps (FastAPI/Starlette). + """ + + route: str + app_is_cross_language: bool = False + route_patterns: Optional[List[str]] = None + + +# Keep in sync with ServeReplicaState in dashboard/client/src/type/serve.ts +class ReplicaState(str, Enum): + STARTING = "STARTING" + UPDATING = "UPDATING" + RECOVERING = "RECOVERING" + RUNNING = "RUNNING" + STOPPING = "STOPPING" + PENDING_MIGRATION = "PENDING_MIGRATION" + + +class DeploymentStatus(str, Enum): + UPDATING = "UPDATING" + HEALTHY = "HEALTHY" + UNHEALTHY = "UNHEALTHY" + DEPLOY_FAILED = "DEPLOY_FAILED" + UPSCALING = "UPSCALING" + DOWNSCALING = "DOWNSCALING" + + +class DeploymentStatusTrigger(str, Enum): + """Explains how a deployment reached its current DeploymentStatus.""" + + UNSPECIFIED = "UNSPECIFIED" + CONFIG_UPDATE_STARTED = "CONFIG_UPDATE_STARTED" + CONFIG_UPDATE_COMPLETED = "CONFIG_UPDATE_COMPLETED" + UPSCALE_COMPLETED = "UPSCALE_COMPLETED" + DOWNSCALE_COMPLETED = "DOWNSCALE_COMPLETED" + AUTOSCALING = "AUTOSCALING" + REPLICA_STARTUP_FAILED = "REPLICA_STARTUP_FAILED" + HEALTH_CHECK_FAILED = "HEALTH_CHECK_FAILED" + INTERNAL_ERROR = "INTERNAL_ERROR" + DELETING = "DELETING" + + +# Internal Enum used to manage deployment state machine +class DeploymentStatusInternalTrigger(str, Enum): + HEALTHY = "HEALTHY" + CONFIG_UPDATE = "CONFIG_UPDATE" + AUTOSCALE_UP = "AUTOSCALE_UP" + AUTOSCALE_DOWN = "AUTOSCALE_DOWN" + # MANUALLY_INCREASE_NUM_REPLICAS and MANUALLY_DECREASE_NUM_REPLICAS are used + # instead of CONFIG_UPDATE when the config update only scales + # the number of replicas. + MANUALLY_INCREASE_NUM_REPLICAS = "MANUALLY_INCREASE_NUM_REPLICAS" + MANUALLY_DECREASE_NUM_REPLICAS = "MANUALLY_DECREASE_NUM_REPLICAS" + REPLICA_STARTUP_FAILED = "REPLICA_STARTUP_FAILED" + HEALTH_CHECK_FAILED = "HEALTH_CHECK_FAILED" + INTERNAL_ERROR = "INTERNAL_ERROR" + DELETE = "DELETE" + + +# List of states in ranked order. +# +# Each ranked state has the format of a tuple with either 1 or 2 items. +# If 1 item: contains a single DeploymentStatus, representing states with +# that DeploymentStatus and any DeploymentStatusTrigger. +# If 2 items: tuple contains a DeploymentStatus and a DeploymentStatusTrigger, +# representing a state with that status and status trigger. +DEPLOYMENT_STATUS_RANKING_ORDER = { + # Status ranking order is defined in a following fashion: + # 0. (Highest) State signaling a deploy failure. + (DeploymentStatus.DEPLOY_FAILED,): 0, + # 1. State signaling any non-deploy failures in the system. + (DeploymentStatus.UNHEALTHY,): 1, + # 2. States signaling the user updated the configuration. + (DeploymentStatus.UPDATING,): 2, + (DeploymentStatus.UPSCALING, DeploymentStatusTrigger.CONFIG_UPDATE_STARTED): 2, + ( + DeploymentStatus.DOWNSCALING, + DeploymentStatusTrigger.CONFIG_UPDATE_STARTED, + ): 2, + # 3. Steady state or autoscaling. + (DeploymentStatus.UPSCALING, DeploymentStatusTrigger.AUTOSCALING): 3, + (DeploymentStatus.DOWNSCALING, DeploymentStatusTrigger.AUTOSCALING): 3, + (DeploymentStatus.HEALTHY,): 3, +} + + +@dataclass(eq=True) +class DeploymentStatusInfo: + name: str + status: DeploymentStatus + status_trigger: DeploymentStatusTrigger + message: str = "" + + @property + def rank(self) -> int: + """Get priority of state based on ranking_order(). + + The ranked order indicates what the status should be of a + hierarchically "higher" resource when derived from a group of + `DeploymentStatusInfo` sub-resources. + """ + + if (self.status,) in DEPLOYMENT_STATUS_RANKING_ORDER: + return DEPLOYMENT_STATUS_RANKING_ORDER[(self.status,)] + elif (self.status, self.status_trigger) in DEPLOYMENT_STATUS_RANKING_ORDER: + return DEPLOYMENT_STATUS_RANKING_ORDER[(self.status, self.status_trigger)] + + def debug_string(self): + return json.dumps(asdict(self), indent=4) + + def _updated_copy( + self, + status: DeploymentStatus = None, + status_trigger: DeploymentStatusTrigger = None, + message: str = "", + ): + """Returns a copy of the current object with the passed in kwargs updated.""" + + return DeploymentStatusInfo( + name=self.name, + status=status if status else self.status, + status_trigger=status_trigger if status_trigger else self.status_trigger, + message=message, + ) + + def update_message(self, message: str): + return self._updated_copy(message=message) + + def handle_transition( + self, + trigger: DeploymentStatusInternalTrigger, + message: str = "", + ) -> "DeploymentStatusInfo": + """Handles a transition from the current state to the next state. + + Args: + trigger: An internal trigger that determines the state + transition. This is the new incoming trigger causing the + transition. + message: The message to set in status info. + + Returns: + New instance of DeploymentStatusInfo representing the + next state to transition to. + """ + + # If there was an unexpected internal error during reconciliation, set + # status to unhealthy immediately and return + if trigger == DeploymentStatusInternalTrigger.INTERNAL_ERROR: + return self._updated_copy( + status=DeploymentStatus.UNHEALTHY, + status_trigger=DeploymentStatusTrigger.INTERNAL_ERROR, + message=message, + ) + + # If deployment is being deleted, set status immediately and return + elif trigger == DeploymentStatusInternalTrigger.DELETE: + return self._updated_copy( + status=DeploymentStatus.UPDATING, + status_trigger=DeploymentStatusTrigger.DELETING, + message=message, + ) + + # Otherwise, go through normal state machine transitions + elif self.status == DeploymentStatus.UPDATING: + # Finished updating configuration and transition to healthy + if trigger == DeploymentStatusInternalTrigger.HEALTHY: + return self._updated_copy( + status=DeploymentStatus.HEALTHY, + status_trigger=DeploymentStatusTrigger.CONFIG_UPDATE_COMPLETED, + message=message, + ) + + # A new configuration has been deployed before deployment + # has finished updating + elif trigger == DeploymentStatusInternalTrigger.CONFIG_UPDATE: + return self._updated_copy( + status=DeploymentStatus.UPDATING, + status_trigger=DeploymentStatusTrigger.CONFIG_UPDATE_STARTED, + message=message, + ) + + # Autoscaling. + elif trigger == DeploymentStatusInternalTrigger.AUTOSCALE_UP: + return self._updated_copy( + status=DeploymentStatus.UPSCALING, + status_trigger=DeploymentStatusTrigger.AUTOSCALING, + message=message, + ) + elif trigger == DeploymentStatusInternalTrigger.AUTOSCALE_DOWN: + return self._updated_copy( + status=DeploymentStatus.DOWNSCALING, + status_trigger=DeploymentStatusTrigger.AUTOSCALING, + message=message, + ) + + # Manually increasing or decreasing num replicas does not + # change the status while deployment is still updating. + elif trigger in { + DeploymentStatusInternalTrigger.MANUALLY_INCREASE_NUM_REPLICAS, + DeploymentStatusInternalTrigger.MANUALLY_DECREASE_NUM_REPLICAS, + }: + return self + + # Failures occurred while a deployment was being updated + elif trigger == DeploymentStatusInternalTrigger.HEALTH_CHECK_FAILED: + return self._updated_copy( + status=DeploymentStatus.DEPLOY_FAILED, + status_trigger=DeploymentStatusTrigger.HEALTH_CHECK_FAILED, + message=message, + ) + elif trigger == DeploymentStatusInternalTrigger.REPLICA_STARTUP_FAILED: + return self._updated_copy( + status=DeploymentStatus.DEPLOY_FAILED, + status_trigger=DeploymentStatusTrigger.REPLICA_STARTUP_FAILED, + message=message, + ) + + elif self.status in {DeploymentStatus.UPSCALING, DeploymentStatus.DOWNSCALING}: + # Failures occurred while upscaling/downscaling + if trigger == DeploymentStatusInternalTrigger.HEALTH_CHECK_FAILED: + return self._updated_copy( + status=DeploymentStatus.UNHEALTHY, + status_trigger=DeploymentStatusTrigger.HEALTH_CHECK_FAILED, + message=message, + ) + elif trigger == DeploymentStatusInternalTrigger.REPLICA_STARTUP_FAILED: + return self._updated_copy( + status=DeploymentStatus.UNHEALTHY, + status_trigger=DeploymentStatusTrigger.REPLICA_STARTUP_FAILED, + message=message, + ) + # Deployment transitions to healthy + elif trigger == DeploymentStatusInternalTrigger.HEALTHY: + return self._updated_copy( + status=DeploymentStatus.HEALTHY, + status_trigger=DeploymentStatusTrigger.UPSCALE_COMPLETED + if self.status == DeploymentStatus.UPSCALING + else DeploymentStatusTrigger.DOWNSCALE_COMPLETED, + message=message, + ) + + # Configuration is updated before scaling is finished + elif trigger == DeploymentStatusInternalTrigger.CONFIG_UPDATE: + return self._updated_copy( + status=DeploymentStatus.UPDATING, + status_trigger=DeploymentStatusTrigger.CONFIG_UPDATE_STARTED, + message=message, + ) + + elif self.status_trigger == DeploymentStatusTrigger.AUTOSCALING: + # Upscale replicas before previous autoscaling has finished + if trigger == DeploymentStatusInternalTrigger.AUTOSCALE_UP: + return self._updated_copy( + status=DeploymentStatus.UPSCALING, + message=message, + ) + # Downscale replicas before previous autoscaling has finished + elif trigger == DeploymentStatusInternalTrigger.AUTOSCALE_DOWN: + return self._updated_copy( + status=DeploymentStatus.DOWNSCALING, + message=message, + ) + # Manually upscale replicas with config update before previous autoscaling has finished + elif ( + trigger + == DeploymentStatusInternalTrigger.MANUALLY_INCREASE_NUM_REPLICAS + ): + return self._updated_copy( + status=DeploymentStatus.UPSCALING, + status_trigger=DeploymentStatusTrigger.CONFIG_UPDATE_STARTED, + message=message, + ) + # Manually downscale replicas with config update before previous autoscaling has finished + elif ( + trigger + == DeploymentStatusInternalTrigger.MANUALLY_DECREASE_NUM_REPLICAS + ): + return self._updated_copy( + status=DeploymentStatus.DOWNSCALING, + status_trigger=DeploymentStatusTrigger.CONFIG_UPDATE_STARTED, + message=message, + ) + + elif self.status_trigger == DeploymentStatusTrigger.CONFIG_UPDATE_STARTED: + # Upscale replicas before previous config update has finished + if ( + trigger + == DeploymentStatusInternalTrigger.MANUALLY_INCREASE_NUM_REPLICAS + ): + return self._updated_copy( + status=DeploymentStatus.UPSCALING, message=message + ) + + # Downscale replicas before previous config update has finished + elif ( + trigger + == DeploymentStatusInternalTrigger.MANUALLY_DECREASE_NUM_REPLICAS + ): + return self._updated_copy( + status=DeploymentStatus.DOWNSCALING, message=message + ) + + elif self.status == DeploymentStatus.HEALTHY: + # Deployment remains healthy + if trigger == DeploymentStatusInternalTrigger.HEALTHY: + return self + + # New configuration is deployed + elif trigger == DeploymentStatusInternalTrigger.CONFIG_UPDATE: + return self._updated_copy( + status=DeploymentStatus.UPDATING, + status_trigger=DeploymentStatusTrigger.CONFIG_UPDATE_STARTED, + message=message, + ) + + # Manually scaling / autoscaling num replicas + elif ( + trigger + == DeploymentStatusInternalTrigger.MANUALLY_INCREASE_NUM_REPLICAS + ): + return self._updated_copy( + status=DeploymentStatus.UPSCALING, + status_trigger=DeploymentStatusTrigger.CONFIG_UPDATE_STARTED, + message=message, + ) + elif ( + trigger + == DeploymentStatusInternalTrigger.MANUALLY_DECREASE_NUM_REPLICAS + ): + return self._updated_copy( + status=DeploymentStatus.DOWNSCALING, + status_trigger=DeploymentStatusTrigger.CONFIG_UPDATE_STARTED, + message=message, + ) + elif trigger == DeploymentStatusInternalTrigger.AUTOSCALE_UP: + return self._updated_copy( + status=DeploymentStatus.UPSCALING, + status_trigger=DeploymentStatusTrigger.AUTOSCALING, + message=message, + ) + elif trigger == DeploymentStatusInternalTrigger.AUTOSCALE_DOWN: + return self._updated_copy( + status=DeploymentStatus.DOWNSCALING, + status_trigger=DeploymentStatusTrigger.AUTOSCALING, + message=message, + ) + + # Health check for one or more replicas has failed + elif trigger == DeploymentStatusInternalTrigger.HEALTH_CHECK_FAILED: + return self._updated_copy( + status=DeploymentStatus.UNHEALTHY, + status_trigger=DeploymentStatusTrigger.HEALTH_CHECK_FAILED, + message=message, + ) + + elif self.status == DeploymentStatus.UNHEALTHY: + # The deployment recovered + if trigger == DeploymentStatusInternalTrigger.HEALTHY: + return self._updated_copy( + status=DeploymentStatus.HEALTHY, + status_trigger=DeploymentStatusTrigger.UNSPECIFIED, + message=message, + ) + + # A new configuration is being deployed. + elif trigger == DeploymentStatusInternalTrigger.CONFIG_UPDATE: + return self._updated_copy( + status=DeploymentStatus.UPDATING, + status_trigger=DeploymentStatusTrigger.CONFIG_UPDATE_STARTED, + message=message, + ) + + # Old failures keep getting triggered, or new failures occurred. + elif trigger == DeploymentStatusInternalTrigger.HEALTH_CHECK_FAILED: + return self._updated_copy( + status=DeploymentStatus.UNHEALTHY, + status_trigger=DeploymentStatusTrigger.HEALTH_CHECK_FAILED, + message=message, + ) + elif trigger == DeploymentStatusInternalTrigger.REPLICA_STARTUP_FAILED: + return self._updated_copy( + status=DeploymentStatus.UNHEALTHY, + status_trigger=DeploymentStatusTrigger.REPLICA_STARTUP_FAILED, + message=message, + ) + + elif self.status == DeploymentStatus.DEPLOY_FAILED: + # The deployment recovered + if trigger == DeploymentStatusInternalTrigger.HEALTHY: + return self._updated_copy( + status=DeploymentStatus.HEALTHY, + status_trigger=DeploymentStatusTrigger.UNSPECIFIED, + message=message, + ) + + # A new configuration is being deployed. + elif trigger == DeploymentStatusInternalTrigger.CONFIG_UPDATE: + return self._updated_copy( + status=DeploymentStatus.UPDATING, + status_trigger=DeploymentStatusTrigger.CONFIG_UPDATE_STARTED, + message=message, + ) + + # Old failures keep getting triggered, or new failures occurred. + elif trigger == DeploymentStatusInternalTrigger.HEALTH_CHECK_FAILED: + return self._updated_copy( + status=DeploymentStatus.DEPLOY_FAILED, + status_trigger=DeploymentStatusTrigger.HEALTH_CHECK_FAILED, + message=message, + ) + elif trigger == DeploymentStatusInternalTrigger.REPLICA_STARTUP_FAILED: + return self._updated_copy( + status=DeploymentStatus.DEPLOY_FAILED, + status_trigger=DeploymentStatusTrigger.REPLICA_STARTUP_FAILED, + message=message, + ) + + # If it's any other transition, ignore it. + return self + + def to_proto(self): + return DeploymentStatusInfoProto( + name=self.name, + status=f"DEPLOYMENT_STATUS_{self.status.name}", + status_trigger=f"DEPLOYMENT_STATUS_TRIGGER_{self.status_trigger.name}", + message=self.message, + ) + + @classmethod + def from_proto(cls, proto: DeploymentStatusInfoProto): + status = DeploymentStatusProto.Name(proto.status)[len("DEPLOYMENT_STATUS_") :] + status_trigger = DeploymentStatusTriggerProto.Name(proto.status_trigger)[ + len("DEPLOYMENT_STATUS_TRIGGER_") : + ] + return cls( + name=proto.name, + status=DeploymentStatus(status), + status_trigger=DeploymentStatusTrigger(status_trigger), + message=proto.message, + ) + + +@dataclass(frozen=True) +class RunningReplicaInfo: + replica_id: ReplicaID + node_id: Optional[str] + node_ip: Optional[str] + availability_zone: Optional[str] + actor_name: str + max_ongoing_requests: int + is_cross_language: bool = False + multiplexed_model_ids: List[str] = field(default_factory=list) + routing_stats: Dict[str, Any] = field(default_factory=dict) + port: Optional[int] = None + + def __post_init__(self): + # Set hash value when object is constructed. + # We use _actor_id to hash the ActorHandle object + # instead of actor_handle itself to make sure + # it is consistently same actor handle between different + # object ids. + + hash_val = hash( + " ".join( + [ + self.replica_id.to_full_id_str(), + self.node_id if self.node_id else "", + self.actor_name, + str(self.max_ongoing_requests), + str(self.is_cross_language), + str(self.multiplexed_model_ids), + str(self.routing_stats), + ] + ) + ) + + # RunningReplicaInfo class set frozen=True, this is the hacky way to set + # new attribute for the class. + object.__setattr__(self, "_hash", hash_val) + + def __hash__(self): + return self._hash + + def __eq__(self, other): + return all( + [ + isinstance(other, RunningReplicaInfo), + self._hash == other._hash, + ] + ) + + def get_actor_handle(self) -> ActorHandle: + actor_handle = ray.get_actor(self.actor_name, namespace=SERVE_NAMESPACE) + return actor_handle + + +@dataclass(frozen=True) +class DeploymentTargetInfo: + is_available: bool + running_replicas: List[RunningReplicaInfo] + + +class ServeDeployMode(str, Enum): + MULTI_APP = "MULTI_APP" + + +class ServeComponentType(str, Enum): + REPLICA = "replica" + + +@dataclass +class RequestRoutingInfo: + """Information about the request routing. + + It includes deployment name (from ReplicaID), replica tag (from ReplicaID), + multiplex model ids, and routing stats. + """ + + replica_id: ReplicaID + multiplexed_model_ids: Optional[List[str]] = None + routing_stats: Optional[Dict[str, Any]] = None + + +@dataclass +class gRPCRequest: + """Sent from the GRPC proxy to replicas on both unary and streaming codepaths.""" + + user_request_proto: Any + + +class RequestProtocol(str, Enum): + UNDEFINED = "UNDEFINED" + HTTP = "HTTP" + GRPC = "gRPC" + + +class DeploymentHandleSource(str, Enum): + UNKNOWN = "UNKNOWN" + PROXY = "PROXY" + REPLICA = "REPLICA" + + +@dataclass +class RequestMetadata: + # request_id can be passed by the client and is only generated by the proxy if the + # client did not pass it in the headers. It is used for logging across different + # system. We can not guarantee the uniqueness of its value. + request_id: str + # internal_request_id is always generated by the proxy and is used for tracking + # request objects. We can assume this is always unique between requests. + internal_request_id: str + + # Method of the user callable to execute. + call_method: str = "__call__" + + # HTTP route path of the request. + route: str = "" + + # Application name. + app_name: str = "" + + # Multiplexed model ID. + multiplexed_model_id: str = "" + + # If this request expects a streaming response. + is_streaming: bool = False + + _http_method: str = "" + + # The protocol to serve this request + _request_protocol: RequestProtocol = RequestProtocol.UNDEFINED + + # Serve's gRPC context associated with this request for getting and setting metadata + grpc_context: Optional[RayServegRPCContext] = None + + _by_reference: bool = True + + @property + def is_http_request(self) -> bool: + return self._request_protocol == RequestProtocol.HTTP + + @property + def is_grpc_request(self) -> bool: + return self._request_protocol == RequestProtocol.GRPC + + +class StreamingHTTPRequest: + """Sent from the HTTP proxy to replicas on the streaming codepath.""" + + def __init__( + self, + asgi_scope: Scope, + *, + proxy_actor_name: Optional[str] = None, + receive_asgi_messages: Optional[ + Callable[[RequestMetadata], Awaitable[bytes]] + ] = None, + ): + self._asgi_scope: Scope = asgi_scope + + if proxy_actor_name is None and receive_asgi_messages is None: + raise ValueError( + "Either proxy_actor_name or receive_asgi_messages must be provided." + ) + + # If receive_asgi_messages is passed, it'll be called directly. + # If proxy_actor_name is passed, the actor will be fetched and its + # `receive_asgi_messages` method will be called. + self._proxy_actor_name: Optional[str] = proxy_actor_name + # Need to keep the actor handle cached to avoid "lost reference to actor" error. + self._cached_proxy_actor: Optional[ActorHandle] = None + self._receive_asgi_messages: Optional[ + Callable[[RequestMetadata], Awaitable[bytes]] + ] = receive_asgi_messages + + @property + def asgi_scope(self) -> Scope: + return self._asgi_scope + + @property + def receive_asgi_messages(self) -> Callable[[RequestMetadata], Awaitable[bytes]]: + if self._receive_asgi_messages is None: + self._cached_proxy_actor = ray.get_actor( + self._proxy_actor_name, namespace=SERVE_NAMESPACE + ) + self._receive_asgi_messages = ( + self._cached_proxy_actor.receive_asgi_messages.remote + ) + + return self._receive_asgi_messages + + +class TargetCapacityDirection(str, Enum): + """Determines what direction the target capacity is scaling.""" + + UP = "UP" + DOWN = "DOWN" + + +@dataclass(frozen=True) +class ReplicaQueueLengthInfo: + accepted: bool + num_ongoing_requests: int + + +@dataclass(frozen=True) +class CreatePlacementGroupRequest: + bundles: List[Dict[str, float]] + strategy: str + target_node_id: str + name: str + runtime_env: Optional[str] = None + + +# This error is used to raise when a by-value DeploymentResponse is converted to an +# ObjectRef. +OBJ_REF_NOT_SUPPORTED_ERROR = RuntimeError( + "Converting by-value DeploymentResponses to ObjectRefs is not supported. " + "Use handle.options(_by_reference=True) to enable it." +) + +RUNNING_REQUESTS_KEY = "running_requests" +ONGOING_REQUESTS_KEY = "ongoing_requests" +QUEUED_REQUESTS_KEY = "queued_requests" + + +@dataclass(order=True) +class TimeStampedValue: + timestamp: float + value: float = field(compare=False) + + +# Type alias for time series data +TimeSeries = List[TimeStampedValue] + + +@dataclass +class HandleMetricReport: + """Report from a deployment handle on queued and ongoing requests. + + Args: + deployment_id: The deployment ID of the deployment handle. + handle_id: The handle ID of the deployment handle. + actor_id: If the deployment handle (from which this metric was + sent) lives on an actor, the ID of that actor. + handle_source: Describes what kind of entity holds this + deployment handle: a Serve proxy, a Serve replica, or + unknown. + aggregated_queued_requests: average number of queued requests at the + handle over the past look_back_period_s seconds. + queued_requests: list of values of queued requests at the + handle over the past look_back_period_s seconds. This is a list because + we take multiple measurements over time. + aggregated_metrics: A map of metric name to the aggregated value over the past + look_back_period_s seconds at the handle for each replica. + metrics: A map of metric name to the list of values running at that handle for each replica + over the past look_back_period_s seconds. This is a list because + we take multiple measurements over time. + timestamp: The time at which this report was created. + """ + + deployment_id: DeploymentID + handle_id: str + actor_id: str + handle_source: DeploymentHandleSource + aggregated_queued_requests: float + queued_requests: TimeSeries + aggregated_metrics: Dict[str, Dict[ReplicaID, float]] + metrics: Dict[str, Dict[ReplicaID, TimeSeries]] + timestamp: float + + @property + def total_requests(self) -> float: + """Total number of queued and running requests.""" + return self.aggregated_queued_requests + sum( + self.aggregated_metrics.get(RUNNING_REQUESTS_KEY, {}).values() + ) + + @property + def is_serve_component_source(self) -> bool: + """Whether the handle source is a Serve actor. + + More specifically, this returns whether a Serve actor tracked + by the controller holds the deployment handle that sent this + report. If the deployment handle lives on a driver, a Ray task, + or an actor that's not a Serve replica, then this returns False. + """ + return self.handle_source in [ + DeploymentHandleSource.PROXY, + DeploymentHandleSource.REPLICA, + ] + + +@dataclass +class ReplicaMetricReport: + """Report from a replica on ongoing requests. + + Args: + replica_id: The replica ID of the replica. + aggregated_metrics: A map of metric name to the aggregated value over the past + look_back_period_s seconds at the replica. + metrics: A map of metric name to the list of values running at that replica + over the past look_back_period_s seconds. This is a list because + we take multiple measurements over time. + timestamp: The time at which this report was created. + """ + + replica_id: ReplicaID + aggregated_metrics: Dict[str, float] + metrics: Dict[str, TimeSeries] + timestamp: float diff --git a/lib/python3.12/site-packages/ray/serve/_private/config.py b/lib/python3.12/site-packages/ray/serve/_private/config.py new file mode 100644 index 0000000000000000000000000000000000000000..22546f618892bc23274f3872ce60184f69ea2c4f --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/_private/config.py @@ -0,0 +1,860 @@ +import inspect +import json +from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Union + +from google.protobuf.descriptor import FieldDescriptor +from google.protobuf.message import Message + +from ray import cloudpickle +from ray._common import ray_option_utils +from ray._common.pydantic_compat import ( + BaseModel, + Field, + NonNegativeFloat, + NonNegativeInt, + PositiveFloat, + PositiveInt, + validator, +) +from ray._common.serialization import pickle_dumps +from ray._common.utils import resources_from_ray_options +from ray.serve._private.constants import ( + DEFAULT_CONSTRUCTOR_RETRY_COUNT, + DEFAULT_GRACEFUL_SHUTDOWN_TIMEOUT_S, + DEFAULT_GRACEFUL_SHUTDOWN_WAIT_LOOP_S, + DEFAULT_HEALTH_CHECK_PERIOD_S, + DEFAULT_HEALTH_CHECK_TIMEOUT_S, + DEFAULT_MAX_ONGOING_REQUESTS, + MAX_REPLICAS_PER_NODE_MAX_VALUE, +) +from ray.serve._private.utils import DEFAULT, DeploymentOptionUpdateType +from ray.serve.config import ( + AggregationFunction, + AutoscalingConfig, + DeploymentMode, + HTTPOptions, + ProxyLocation, + RequestRouterConfig, +) +from ray.serve.generated.serve_pb2 import ( + AutoscalingConfig as AutoscalingConfigProto, + DeploymentConfig as DeploymentConfigProto, + DeploymentLanguage, + EncodingType as EncodingTypeProto, + LoggingConfig as LoggingConfigProto, + ReplicaConfig as ReplicaConfigProto, + RequestRouterConfig as RequestRouterConfigProto, +) +from ray.util.placement_group import validate_placement_group + + +def _needs_pickle(deployment_language: DeploymentLanguage, is_cross_language: bool): + """From Serve client API's perspective, decide whether pickling is needed.""" + if deployment_language == DeploymentLanguage.PYTHON and not is_cross_language: + # Python client deploying Python replicas. + return True + elif deployment_language == DeploymentLanguage.JAVA and is_cross_language: + # Python client deploying Java replicas, + # using xlang serialization via cloudpickle. + return True + else: + return False + + +def _proto_to_dict(proto: Message) -> Dict: + """Recursively convert a protobuf into a Python dictionary. + + This is an alternative to protobuf's `MessageToDict`. Unlike + `MessageToDict`, this function doesn't add an extra base64 + encoding to bytes when constructing a json response. + """ + data = {} + # Fill data with non-empty fields. + for field, value in proto.ListFields(): + # Handle repeated fields + if field.label == FieldDescriptor.LABEL_REPEATED: + # if we dont do this block the repeated field will be a list of + # `google.protobuf.internal.containers.RepeatedScalarFieldContainer + # Explicitly convert to list + if field.type == FieldDescriptor.TYPE_MESSAGE: + data[field.name] = [ + _proto_to_dict(v) for v in value + ] # Convert each item + else: + data[field.name] = list(value) # Convert to list directly + # Recursively call if the field is another protobuf. + elif field.type == FieldDescriptor.TYPE_MESSAGE: + data[field.name] = _proto_to_dict(value) + else: + data[field.name] = value + + # Fill data default values. + for field in proto.DESCRIPTOR.fields: + if ( + field.name not in data # skip the fields that are already set + and field.type != FieldDescriptor.TYPE_MESSAGE # skip nested messages + and not field.containing_oneof # skip optional fields + ): + data[field.name] = field.default_value + return data + + +class DeploymentConfig(BaseModel): + """Internal datastructure wrapping config options for a deployment. + + Args: + num_replicas: The number of processes to start up that + handles requests to this deployment. Defaults to 1. + max_ongoing_requests: The maximum number of queries + that is sent to a replica of this deployment without receiving + a response. Defaults to 5. + max_queued_requests: Maximum number of requests to this deployment that will be + queued at each *caller* (proxy or DeploymentHandle). Once this limit is + reached, subsequent requests will raise a BackPressureError (for handles) or + return an HTTP 503 status code (for HTTP requests). Defaults to -1 (no + limit). + user_config: Arguments to pass to the reconfigure + method of the deployment. The reconfigure method is called if + user_config is not None. Must be JSON-serializable. + graceful_shutdown_wait_loop_s: Duration + that deployment replicas wait until there is no more work to + be done before shutting down. + graceful_shutdown_timeout_s: Controller waits for this duration + to forcefully kill the replica for shutdown. + health_check_period_s: Frequency at which the controller health + checks replicas. + health_check_timeout_s: Timeout that the controller waits for a + response from the replica's health check before marking it + unhealthy. + autoscaling_config: Autoscaling configuration. + logging_config: Configuration for deployment logs. + user_configured_option_names: The names of options manually + configured by the user. + request_router_config: Configuration for deployment request router. + max_constructor_retry_count: Maximum number of times to retry the + deployment constructor. Defaults to 20. + """ + + num_replicas: Optional[NonNegativeInt] = Field( + default=1, update_type=DeploymentOptionUpdateType.LightWeight + ) + max_ongoing_requests: PositiveInt = Field( + default=DEFAULT_MAX_ONGOING_REQUESTS, + update_type=DeploymentOptionUpdateType.NeedsActorReconfigure, + ) + max_queued_requests: int = Field( + default=-1, + update_type=DeploymentOptionUpdateType.LightWeight, + ) + user_config: Any = Field( + default=None, update_type=DeploymentOptionUpdateType.NeedsActorReconfigure + ) + + graceful_shutdown_timeout_s: NonNegativeFloat = Field( + default=DEFAULT_GRACEFUL_SHUTDOWN_TIMEOUT_S, + update_type=DeploymentOptionUpdateType.NeedsReconfigure, + ) + graceful_shutdown_wait_loop_s: NonNegativeFloat = Field( + default=DEFAULT_GRACEFUL_SHUTDOWN_WAIT_LOOP_S, + update_type=DeploymentOptionUpdateType.NeedsActorReconfigure, + ) + + health_check_period_s: PositiveFloat = Field( + default=DEFAULT_HEALTH_CHECK_PERIOD_S, + update_type=DeploymentOptionUpdateType.NeedsReconfigure, + ) + health_check_timeout_s: PositiveFloat = Field( + default=DEFAULT_HEALTH_CHECK_TIMEOUT_S, + update_type=DeploymentOptionUpdateType.NeedsReconfigure, + ) + + autoscaling_config: Optional[AutoscalingConfig] = Field( + default=None, update_type=DeploymentOptionUpdateType.NeedsActorReconfigure + ) + + request_router_config: RequestRouterConfig = Field( + default_factory=RequestRouterConfig, + update_type=DeploymentOptionUpdateType.NeedsActorReconfigure, + ) + + # This flag is used to let replica know they are deployed from + # a different language. + is_cross_language: bool = False + + # This flag is used to let controller know which language does + # the deployment use. + deployment_language: Any = DeploymentLanguage.PYTHON + + version: Optional[str] = Field( + default=None, + update_type=DeploymentOptionUpdateType.HeavyWeight, + ) + + logging_config: Optional[dict] = Field( + default=None, + update_type=DeploymentOptionUpdateType.NeedsActorReconfigure, + ) + + max_constructor_retry_count: PositiveInt = Field( + default=DEFAULT_CONSTRUCTOR_RETRY_COUNT, + update_type=DeploymentOptionUpdateType.NeedsReconfigure, + ) + + # Contains the names of deployment options manually set by the user + user_configured_option_names: Set[str] = set() + + class Config: + validate_assignment = True + arbitrary_types_allowed = True + + @validator("user_config", always=True) + def user_config_json_serializable(cls, v): + if isinstance(v, bytes): + return v + if v is not None: + try: + json.dumps(v) + except TypeError as e: + raise ValueError(f"user_config is not JSON-serializable: {str(e)}.") + + return v + + @validator("logging_config", always=True) + def logging_config_valid(cls, v): + if v is None: + return v + if not isinstance(v, dict): + raise TypeError( + f"Got invalid type '{type(v)}' for logging_config. " + "Expected a dictionary." + ) + # Handle default value + from ray.serve.schema import LoggingConfig + + v = LoggingConfig(**v).dict() + return v + + @validator("max_queued_requests", always=True) + def validate_max_queued_requests(cls, v): + if not isinstance(v, int): + raise TypeError("max_queued_requests must be an integer.") + + if v < 1 and v != -1: + raise ValueError( + "max_queued_requests must be -1 (no limit) or a positive integer." + ) + + return v + + def needs_pickle(self): + return _needs_pickle(self.deployment_language, self.is_cross_language) + + def to_proto(self): + data = self.dict() + if data.get("user_config") is not None: + if self.needs_pickle(): + data["user_config"] = cloudpickle.dumps(data["user_config"]) + if data.get("autoscaling_config"): + # By setting the serialized policy def, on the protobuf level, AutoscalingConfig constructor will not + # try to import the policy from the string import path when the protobuf is deserialized on the controller side + data["autoscaling_config"]["policy"][ + "_serialized_policy_def" + ] = self.autoscaling_config.policy._serialized_policy_def + data["autoscaling_config"] = AutoscalingConfigProto( + **data["autoscaling_config"] + ) + if data.get("request_router_config"): + router_kwargs = data["request_router_config"].get("request_router_kwargs") + if router_kwargs is not None: + if not router_kwargs: + data["request_router_config"]["request_router_kwargs"] = b"" + elif self.needs_pickle(): + # Protobuf requires bytes, so we need to pickle + data["request_router_config"][ + "request_router_kwargs" + ] = cloudpickle.dumps(router_kwargs) + else: + raise ValueError( + "Non-empty request_router_kwargs not supported" + f"for cross-language deployments. Got: {router_kwargs}" + ) + # By setting the serialized request router cls, on the protobuf level, RequestRouterConfig constructor will not + # try to import the request router cls from the string import path when the protobuf is deserialized on the controller side + data["request_router_config"][ + "_serialized_request_router_cls" + ] = self.request_router_config._serialized_request_router_cls + data["request_router_config"] = RequestRouterConfigProto( + **data["request_router_config"] + ) + if data.get("logging_config"): + if "encoding" in data["logging_config"]: + data["logging_config"]["encoding"] = EncodingTypeProto.Value( + data["logging_config"]["encoding"] + ) + data["logging_config"] = LoggingConfigProto(**data["logging_config"]) + data["user_configured_option_names"] = list( + data["user_configured_option_names"] + ) + return DeploymentConfigProto(**data) + + def to_proto_bytes(self): + return self.to_proto().SerializeToString() + + @classmethod + def from_proto(cls, proto: DeploymentConfigProto): + data = _proto_to_dict(proto) + deployment_language = ( + data["deployment_language"] + if "deployment_language" in data + else DeploymentLanguage.PYTHON + ) + is_cross_language = ( + data["is_cross_language"] if "is_cross_language" in data else False + ) + needs_pickle = _needs_pickle(deployment_language, is_cross_language) + if "user_config" in data: + if data["user_config"] != b"": + if needs_pickle: + data["user_config"] = cloudpickle.loads(proto.user_config) + else: + data["user_config"] = proto.user_config + else: + data["user_config"] = None + if "request_router_config" in data: + if "request_router_kwargs" in data["request_router_config"]: + request_router_kwargs = data["request_router_config"][ + "request_router_kwargs" + ] + if request_router_kwargs != b"": + if needs_pickle: + data["request_router_config"][ + "request_router_kwargs" + ] = cloudpickle.loads( + proto.request_router_config.request_router_kwargs + ) + else: + data["request_router_config"][ + "request_router_kwargs" + ] = proto.request_router_config.request_router_kwargs + else: + data["request_router_config"]["request_router_kwargs"] = {} + + data["request_router_config"] = RequestRouterConfig( + **data["request_router_config"] + ) + if "autoscaling_config" in data: + if not data["autoscaling_config"].get("upscale_smoothing_factor"): + data["autoscaling_config"]["upscale_smoothing_factor"] = None + if not data["autoscaling_config"].get("downscale_smoothing_factor"): + data["autoscaling_config"]["downscale_smoothing_factor"] = None + if not data["autoscaling_config"].get("upscaling_factor"): + data["autoscaling_config"]["upscaling_factor"] = None + if not data["autoscaling_config"].get("downscaling_factor"): + data["autoscaling_config"]["downscaling_factor"] = None + if not data["autoscaling_config"].get("target_ongoing_requests"): + data["autoscaling_config"]["target_ongoing_requests"] = None + if not data["autoscaling_config"].get("aggregation_function"): + data["autoscaling_config"][ + "aggregation_function" + ] = AggregationFunction.MEAN + data["autoscaling_config"] = AutoscalingConfig(**data["autoscaling_config"]) + if "version" in data: + if data["version"] == "": + data["version"] = None + if "user_configured_option_names" in data: + data["user_configured_option_names"] = set( + data["user_configured_option_names"] + ) + if "logging_config" in data: + if "encoding" in data["logging_config"]: + data["logging_config"]["encoding"] = EncodingTypeProto.Name( + data["logging_config"]["encoding"] + ) + + return cls(**data) + + @classmethod + def from_proto_bytes(cls, proto_bytes: bytes): + proto = DeploymentConfigProto.FromString(proto_bytes) + return cls.from_proto(proto) + + @classmethod + def from_default(cls, **kwargs): + """Creates a default DeploymentConfig and overrides it with kwargs. + + Ignores any kwargs set to DEFAULT.VALUE. + + Raises: + TypeError: when a keyword that's not an argument to the class is + passed in. + """ + + config = cls() + valid_config_options = set(config.dict().keys()) + + # Friendly error if a non-DeploymentConfig kwarg was passed in + for key, val in kwargs.items(): + if key not in valid_config_options: + raise TypeError( + f'Got invalid Deployment config option "{key}" ' + f"(with value {val}) as keyword argument. All Deployment " + "config options must come from this list: " + f"{list(valid_config_options)}." + ) + + kwargs = {key: val for key, val in kwargs.items() if val != DEFAULT.VALUE} + + for key, val in kwargs.items(): + config.__setattr__(key, val) + + return config + + +def handle_num_replicas_auto( + max_ongoing_requests: Union[int, DEFAULT], + autoscaling_config: Optional[Union[Dict, AutoscalingConfig, DEFAULT]], +): + """Return modified `max_ongoing_requests` and `autoscaling_config` + for when num_replicas="auto". + + If `autoscaling_config` is unspecified, returns the modified value + AutoscalingConfig.default(). + If it is specified, the specified fields in `autoscaling_config` + override that of AutoscalingConfig.default(). + """ + + if autoscaling_config in [DEFAULT.VALUE, None]: + # If autoscaling config wasn't specified, use default + # configuration + autoscaling_config = AutoscalingConfig.default() + else: + # If autoscaling config was specified, values specified in + # autoscaling config overrides the default configuration + default_config = AutoscalingConfig.default().dict(exclude_unset=True) + autoscaling_config = ( + autoscaling_config + if isinstance(autoscaling_config, dict) + else autoscaling_config.dict(exclude_unset=True) + ) + default_config.update(autoscaling_config) + autoscaling_config = AutoscalingConfig(**default_config) + + return max_ongoing_requests, autoscaling_config + + +class ReplicaConfig: + """Internal datastructure wrapping config options for a deployment's replicas. + + Provides five main properties (see property docstrings for more info): + deployment_def: the code, or a reference to the code, that this + replica should run. + init_args: the deployment_def's init_args. + init_kwargs: the deployment_def's init_kwargs. + ray_actor_options: the Ray actor options to pass into the replica's + actor. + resource_dict: contains info on this replica's actor's resource needs. + + Offers a serialized equivalent (e.g. serialized_deployment_def) for + deployment_def, init_args, and init_kwargs. Deserializes these properties + when they're first accessed, if they were not passed in directly through + create(). + + Use the classmethod create() to make a ReplicaConfig with the deserialized + properties. + + Note: overwriting or setting any property after the ReplicaConfig has been + constructed is currently undefined behavior. The config's fields should not + be modified externally after it is created. + """ + + def __init__( + self, + deployment_def_name: str, + serialized_deployment_def: bytes, + serialized_init_args: bytes, + serialized_init_kwargs: bytes, + ray_actor_options: Dict, + placement_group_bundles: Optional[List[Dict[str, float]]] = None, + placement_group_strategy: Optional[str] = None, + max_replicas_per_node: Optional[int] = None, + needs_pickle: bool = True, + ): + """Construct a ReplicaConfig with serialized properties. + + All parameters are required. See classmethod create() for defaults. + """ + self.deployment_def_name = deployment_def_name + + # Store serialized versions of code properties. + self.serialized_deployment_def = serialized_deployment_def + self.serialized_init_args = serialized_init_args + self.serialized_init_kwargs = serialized_init_kwargs + + # Deserialize properties when first accessed. See @property methods. + self._deployment_def = None + self._init_args = None + self._init_kwargs = None + + # Configure ray_actor_options. These are the Ray options ultimately + # passed into the replica's actor when it's created. + self.ray_actor_options = ray_actor_options + + self.placement_group_bundles = placement_group_bundles + self.placement_group_strategy = placement_group_strategy + + self.max_replicas_per_node = max_replicas_per_node + + self._validate() + + # Create resource_dict. This contains info about the replica's resource + # needs. It does NOT set the replica's resource usage. That's done by + # the ray_actor_options. + self.resource_dict = resources_from_ray_options(self.ray_actor_options) + self.needs_pickle = needs_pickle + + def _validate(self): + self._validate_ray_actor_options() + self._validate_placement_group_options() + self._validate_max_replicas_per_node() + + if ( + self.max_replicas_per_node is not None + and self.placement_group_bundles is not None + ): + raise ValueError( + "Setting max_replicas_per_node is not allowed when " + "placement_group_bundles is provided." + ) + + def update( + self, + ray_actor_options: dict, + placement_group_bundles: Optional[List[Dict[str, float]]] = None, + placement_group_strategy: Optional[str] = None, + max_replicas_per_node: Optional[int] = None, + ): + self.ray_actor_options = ray_actor_options + + self.placement_group_bundles = placement_group_bundles + self.placement_group_strategy = placement_group_strategy + + self.max_replicas_per_node = max_replicas_per_node + + self._validate() + + self.resource_dict = resources_from_ray_options(self.ray_actor_options) + + @classmethod + def create( + cls, + deployment_def: Union[Callable, str], + init_args: Optional[Tuple[Any]] = None, + init_kwargs: Optional[Dict[Any, Any]] = None, + ray_actor_options: Optional[Dict] = None, + placement_group_bundles: Optional[List[Dict[str, float]]] = None, + placement_group_strategy: Optional[str] = None, + max_replicas_per_node: Optional[int] = None, + deployment_def_name: Optional[str] = None, + ): + """Create a ReplicaConfig from deserialized parameters.""" + + if not callable(deployment_def) and not isinstance(deployment_def, str): + raise TypeError("@serve.deployment must be called on a class or function.") + + if not (init_args is None or isinstance(init_args, (tuple, list))): + raise TypeError("init_args must be a tuple.") + + if not (init_kwargs is None or isinstance(init_kwargs, dict)): + raise TypeError("init_kwargs must be a dict.") + + if inspect.isfunction(deployment_def): + if init_args: + raise ValueError("init_args not supported for function deployments.") + elif init_kwargs: + raise ValueError("init_kwargs not supported for function deployments.") + + if not isinstance(deployment_def, (Callable, str)): + raise TypeError( + f'Got invalid type "{type(deployment_def)}" for ' + "deployment_def. Expected deployment_def to be a " + "class, function, or string." + ) + # Set defaults + if init_args is None: + init_args = () + if init_kwargs is None: + init_kwargs = {} + if ray_actor_options is None: + ray_actor_options = {} + if deployment_def_name is None: + if isinstance(deployment_def, str): + deployment_def_name = deployment_def + else: + deployment_def_name = deployment_def.__name__ + + config = cls( + deployment_def_name, + pickle_dumps( + deployment_def, + f"Could not serialize the deployment {repr(deployment_def)}", + ), + pickle_dumps(init_args, "Could not serialize the deployment init args"), + pickle_dumps(init_kwargs, "Could not serialize the deployment init kwargs"), + ray_actor_options, + placement_group_bundles, + placement_group_strategy, + max_replicas_per_node, + ) + + config._deployment_def = deployment_def + config._init_args = init_args + config._init_kwargs = init_kwargs + + return config + + def _validate_ray_actor_options(self): + if not isinstance(self.ray_actor_options, dict): + raise TypeError( + f'Got invalid type "{type(self.ray_actor_options)}" for ' + "ray_actor_options. Expected a dictionary." + ) + # Please keep this in sync with the docstring for the ray_actor_options + # kwarg in api.py. + allowed_ray_actor_options = { + # Resource options + "accelerator_type", + "memory", + "num_cpus", + "num_gpus", + "resources", + # Other options + "runtime_env", + } + + for option in self.ray_actor_options: + if option not in allowed_ray_actor_options: + raise ValueError( + f"Specifying '{option}' in ray_actor_options is not allowed. " + f"Allowed options: {allowed_ray_actor_options}" + ) + ray_option_utils.validate_actor_options(self.ray_actor_options, in_options=True) + + # Set Serve replica defaults + if self.ray_actor_options.get("num_cpus") is None: + self.ray_actor_options["num_cpus"] = 1 + + def _validate_max_replicas_per_node(self) -> None: + if self.max_replicas_per_node is None: + return + if not isinstance(self.max_replicas_per_node, int): + raise TypeError( + f"Get invalid type '{type(self.max_replicas_per_node)}' for " + "max_replicas_per_node. Expected None or an integer " + f"in the range of [1, {MAX_REPLICAS_PER_NODE_MAX_VALUE}]." + ) + if ( + self.max_replicas_per_node < 1 + or self.max_replicas_per_node > MAX_REPLICAS_PER_NODE_MAX_VALUE + ): + raise ValueError( + f"Invalid max_replicas_per_node {self.max_replicas_per_node}. " + "Valid values are None or an integer " + f"in the range of [1, {MAX_REPLICAS_PER_NODE_MAX_VALUE}]." + ) + + def _validate_placement_group_options(self) -> None: + if self.placement_group_strategy is not None: + if self.placement_group_bundles is None: + raise ValueError( + "If `placement_group_strategy` is provided, " + "`placement_group_bundles` must also be provided." + ) + + if self.placement_group_bundles is not None: + validate_placement_group( + bundles=self.placement_group_bundles, + strategy=self.placement_group_strategy or "PACK", + lifetime="detached", + ) + + resource_error_prefix = ( + "When using `placement_group_bundles`, the replica actor " + "will be placed in the first bundle, so the resource " + "requirements for the actor must be a subset of the first " + "bundle." + ) + + first_bundle = self.placement_group_bundles[0] + + # Validate that the replica actor fits in the first bundle. + bundle_cpu = first_bundle.get("CPU", 0) + replica_actor_num_cpus = self.ray_actor_options.get("num_cpus", 0) + if bundle_cpu < replica_actor_num_cpus: + raise ValueError( + f"{resource_error_prefix} `num_cpus` for the actor is " + f"{replica_actor_num_cpus}, but the bundle only has " + f"{bundle_cpu} `CPU` specified." + ) + + bundle_gpu = first_bundle.get("GPU", 0) + replica_actor_num_gpus = self.ray_actor_options.get("num_gpus", 0) + if bundle_gpu < replica_actor_num_gpus: + raise ValueError( + f"{resource_error_prefix} `num_gpus` for the actor is " + f"{replica_actor_num_gpus}, but the bundle only has " + f"{bundle_gpu} `GPU` specified." + ) + + replica_actor_resources = self.ray_actor_options.get("resources", {}) + for actor_resource, actor_value in replica_actor_resources.items(): + bundle_value = first_bundle.get(actor_resource, 0) + if bundle_value < actor_value: + raise ValueError( + f"{resource_error_prefix} `{actor_resource}` requirement " + f"for the actor is {actor_value}, but the bundle only " + f"has {bundle_value} `{actor_resource}` specified." + ) + + @property + def deployment_def(self) -> Union[Callable, str]: + """The code, or a reference to the code, that this replica runs. + + For Python replicas, this can be one of the following: + - Function (Callable) + - Class (Callable) + - Import path (str) + + For Java replicas, this can be one of the following: + - Class path (str) + """ + if self._deployment_def is None: + if self.needs_pickle: + self._deployment_def = cloudpickle.loads(self.serialized_deployment_def) + else: + self._deployment_def = self.serialized_deployment_def.decode( + encoding="utf-8" + ) + + return self._deployment_def + + @property + def init_args(self) -> Optional[Union[Tuple[Any], bytes]]: + """The init_args for a Python class. + + This property is only meaningful if deployment_def is a Python class. + Otherwise, it is None. + """ + if self._init_args is None: + if self.needs_pickle: + self._init_args = cloudpickle.loads(self.serialized_init_args) + else: + self._init_args = self.serialized_init_args + + return self._init_args + + @property + def init_kwargs(self) -> Optional[Tuple[Any]]: + """The init_kwargs for a Python class. + + This property is only meaningful if deployment_def is a Python class. + Otherwise, it is None. + """ + + if self._init_kwargs is None: + self._init_kwargs = cloudpickle.loads(self.serialized_init_kwargs) + + return self._init_kwargs + + @classmethod + def from_proto(cls, proto: ReplicaConfigProto, needs_pickle: bool = True): + return ReplicaConfig( + proto.deployment_def_name, + proto.deployment_def, + proto.init_args if proto.init_args != b"" else None, + proto.init_kwargs if proto.init_kwargs != b"" else None, + json.loads(proto.ray_actor_options), + json.loads(proto.placement_group_bundles) + if proto.placement_group_bundles + else None, + proto.placement_group_strategy + if proto.placement_group_strategy != "" + else None, + proto.max_replicas_per_node if proto.max_replicas_per_node else None, + needs_pickle, + ) + + @classmethod + def from_proto_bytes(cls, proto_bytes: bytes, needs_pickle: bool = True): + proto = ReplicaConfigProto.FromString(proto_bytes) + return cls.from_proto(proto, needs_pickle) + + def to_proto(self): + return ReplicaConfigProto( + deployment_def_name=self.deployment_def_name, + deployment_def=self.serialized_deployment_def, + init_args=self.serialized_init_args, + init_kwargs=self.serialized_init_kwargs, + ray_actor_options=json.dumps(self.ray_actor_options), + placement_group_bundles=json.dumps(self.placement_group_bundles) + if self.placement_group_bundles is not None + else "", + placement_group_strategy=self.placement_group_strategy, + max_replicas_per_node=self.max_replicas_per_node + if self.max_replicas_per_node is not None + else 0, + ) + + def to_proto_bytes(self): + return self.to_proto().SerializeToString() + + +def prepare_imperative_http_options( + proxy_location: Union[None, str, ProxyLocation], + http_options: Union[None, dict, HTTPOptions], +) -> HTTPOptions: + """Prepare `HTTPOptions` with a resolved `location` based on `proxy_location` and `http_options`. + + Precedence: + - If `proxy_location` is provided, it overrides any `location` in `http_options`. + - Else if `http_options` specifies a `location` explicitly (HTTPOptions(...) or dict with 'location'), keep it. + - Else (no `proxy_location` and no explicit `location`) set `location` to `DeploymentMode.EveryNode`. + A bare `HTTPOptions()` counts as an explicit default (`HeadOnly`). + + Args: + proxy_location: Optional ProxyLocation (or its string representation). + http_options: Optional HTTPOptions instance or dict. If None, a new HTTPOptions() is created. + + Returns: + HTTPOptions: New instance with resolved location. + + Note: + 1. Default ProxyLocation (when unspecified) resolves to DeploymentMode.EveryNode. + 2. Default HTTPOptions() location is DeploymentMode.HeadOnly. + 3. `HTTPOptions` is used in `imperative` mode (Python API) cluster set-up. + `Declarative` mode (CLI / REST) uses `HTTPOptionsSchema`. + + Raises: + ValueError: If http_options is not None, dict, or HTTPOptions. + """ + if http_options is None: + location_set_explicitly = False + http_options = HTTPOptions() + elif isinstance(http_options, dict): + location_set_explicitly = "location" in http_options + http_options = HTTPOptions(**http_options) + elif isinstance(http_options, HTTPOptions): + # empty `HTTPOptions()` is considered as user specified the default location value `HeadOnly` explicitly + location_set_explicitly = True + http_options = HTTPOptions(**http_options.dict(exclude_unset=True)) + else: + raise ValueError( + f"Unexpected type for http_options: `{type(http_options).__name__}`" + ) + + if proxy_location is None: + if not location_set_explicitly: + http_options.location = DeploymentMode.EveryNode + else: + http_options.location = ProxyLocation._to_deployment_mode(proxy_location) + + return http_options diff --git a/lib/python3.12/site-packages/ray/serve/_private/constants.py b/lib/python3.12/site-packages/ray/serve/_private/constants.py new file mode 100644 index 0000000000000000000000000000000000000000..4035e04d36fc7dc0f0845af9b572db68905a3c68 --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/_private/constants.py @@ -0,0 +1,523 @@ +from typing import List + +from ray.serve._private.constants_utils import ( + get_env_bool, + get_env_float, + get_env_float_non_negative, + get_env_float_positive, + get_env_int, + get_env_int_non_negative, + get_env_int_positive, + get_env_str, + parse_latency_buckets, + str_to_list, +) + +#: Logger used by serve components +SERVE_LOGGER_NAME = "ray.serve" + +#: Actor name used to register controller +SERVE_CONTROLLER_NAME = "SERVE_CONTROLLER_ACTOR" + +#: Actor name used to register HTTP proxy actor +SERVE_PROXY_NAME = "SERVE_PROXY_ACTOR" + +#: Ray namespace used for all Serve actors +SERVE_NAMESPACE = "serve" + +#: HTTP Host +DEFAULT_HTTP_HOST = get_env_str("RAY_SERVE_DEFAULT_HTTP_HOST", "127.0.0.1") + +#: HTTP Port +DEFAULT_HTTP_PORT = get_env_int("RAY_SERVE_DEFAULT_HTTP_PORT", 8000) + +#: Uvicorn timeout_keep_alive Config +DEFAULT_UVICORN_KEEP_ALIVE_TIMEOUT_S = 90 + +#: gRPC Port +DEFAULT_GRPC_PORT = get_env_int("RAY_SERVE_DEFAULT_GRPC_PORT", 9000) + +#: Default Serve application name +SERVE_DEFAULT_APP_NAME = "default" + +#: Max concurrency +ASYNC_CONCURRENCY = int(1e6) + +# How long to sleep between control loop cycles on the controller. +CONTROL_LOOP_INTERVAL_S = get_env_float_non_negative( + "RAY_SERVE_CONTROL_LOOP_INTERVAL_S", 0.1 +) + +#: Max time to wait for HTTP proxy in `serve.start()`. +HTTP_PROXY_TIMEOUT = 60 + +#: Max retry count for allowing failures in replica constructor. +#: If no replicas at target version is running by the time we're at +#: max constructor retry count, deploy() is considered failed. +#: By default we set threshold as min(num_replicas * 3, this value) +#: This constant is deprecated and will be removed in the future. +#: Please use 'max_constructor_retry_count' instead in configurations. +MAX_DEPLOYMENT_CONSTRUCTOR_RETRY_COUNT = get_env_int( + "RAY_SERVE_MAX_DEPLOYMENT_CONSTRUCTOR_RETRY_COUNT", + get_env_int("MAX_DEPLOYMENT_CONSTRUCTOR_RETRY_COUNT", None), +) + +# Max retry on deployment constructor is +# min(num_replicas * MAX_PER_REPLICA_RETRY_COUNT, MAX_DEPLOYMENT_CONSTRUCTOR_RETRY_COUNT) +MAX_PER_REPLICA_RETRY_COUNT = get_env_int( + "RAY_SERVE_MAX_PER_REPLICA_RETRY_COUNT", + get_env_int("MAX_PER_REPLICA_RETRY_COUNT", 3), +) + + +# If you are wondering why we are using histogram buckets, please refer to +# https://prometheus.io/docs/practices/histograms/ +# short answer is that its cheaper to calculate percentiles on the histogram +# than to calculate them on raw data, both in terms of time and space. + +#: Default histogram buckets for latency tracker. +DEFAULT_LATENCY_BUCKET_MS = [ + 1, + 2, + 5, + 10, + 20, + 50, + 100, + 200, + 300, + 400, + 500, + 1000, + 2000, + # 5 seconds + 5000, + # 10 seconds + 10000, + # 60 seconds + 60000, + # 2min + 120000, + # 5 min + 300000, + # 10 min + 600000, +] + +# Example usage: +# RAY_SERVE_REQUEST_LATENCY_BUCKET_MS="1,2,3,4" +# RAY_SERVE_MODEL_LOAD_LATENCY_BUCKET_MS="1,2,3,4" +#: Histogram buckets for request latency. +REQUEST_LATENCY_BUCKETS_MS = parse_latency_buckets( + get_env_str( + "RAY_SERVE_REQUEST_LATENCY_BUCKETS_MS", + get_env_str("REQUEST_LATENCY_BUCKETS_MS", ""), + ), + DEFAULT_LATENCY_BUCKET_MS, +) +#: Histogram buckets for model load/unload latency. +MODEL_LOAD_LATENCY_BUCKETS_MS = parse_latency_buckets( + get_env_str( + "RAY_SERVE_MODEL_LOAD_LATENCY_BUCKETS_MS", + get_env_str("MODEL_LOAD_LATENCY_BUCKETS_MS", ""), + ), + DEFAULT_LATENCY_BUCKET_MS, +) + +#: Name of deployment health check method implemented by user. +HEALTH_CHECK_METHOD = "check_health" + +#: Name of deployment reconfiguration method implemented by user. +RECONFIGURE_METHOD = "reconfigure" + +SERVE_ROOT_URL_ENV_KEY = "RAY_SERVE_ROOT_URL" + +#: Limit the number of cached handles because each handle has long poll +#: overhead. See https://github.com/ray-project/ray/issues/18980 +MAX_CACHED_HANDLES = get_env_int_positive( + "RAY_SERVE_MAX_CACHED_HANDLES", get_env_int_positive("MAX_CACHED_HANDLES", 100) +) + +#: Because ServeController will accept one long poll request per handle, its +#: concurrency needs to scale as O(num_handles) +CONTROLLER_MAX_CONCURRENCY = get_env_int_positive( + "RAY_SERVE_CONTROLLER_MAX_CONCURRENCY", + get_env_int_positive("CONTROLLER_MAX_CONCURRENCY", 15_000), +) + +DEFAULT_GRACEFUL_SHUTDOWN_TIMEOUT_S = 20 +DEFAULT_GRACEFUL_SHUTDOWN_WAIT_LOOP_S = 2 +DEFAULT_HEALTH_CHECK_PERIOD_S = 10 +DEFAULT_HEALTH_CHECK_TIMEOUT_S = 30 +DEFAULT_MAX_ONGOING_REQUESTS = 5 +DEFAULT_TARGET_ONGOING_REQUESTS = 2 +DEFAULT_CONSUMER_CONCURRENCY = DEFAULT_MAX_ONGOING_REQUESTS +DEFAULT_CONSTRUCTOR_RETRY_COUNT = 20 + +# HTTP Proxy health check configs +PROXY_HEALTH_CHECK_TIMEOUT_S = get_env_float_positive( + "RAY_SERVE_PROXY_HEALTH_CHECK_TIMEOUT_S", 10.0 +) + +PROXY_HEALTH_CHECK_PERIOD_S = get_env_float_positive( + "RAY_SERVE_PROXY_HEALTH_CHECK_PERIOD_S", 10.0 +) +PROXY_READY_CHECK_TIMEOUT_S = get_env_float_positive( + "RAY_SERVE_PROXY_READY_CHECK_TIMEOUT_S", 5.0 +) + +# Number of times in a row that a HTTP proxy must fail the health check before +# being marked unhealthy. +PROXY_HEALTH_CHECK_UNHEALTHY_THRESHOLD = 3 + +# The minimum drain period for a HTTP proxy. +PROXY_MIN_DRAINING_PERIOD_S = get_env_float_positive( + "RAY_SERVE_PROXY_MIN_DRAINING_PERIOD_S", 30.0 +) +# The time in seconds that the http proxy state waits before +# rechecking whether the proxy actor is drained or not. +PROXY_DRAIN_CHECK_PERIOD_S = 5 + +#: Number of times in a row that a replica must fail the health check before +#: being marked unhealthy. +REPLICA_HEALTH_CHECK_UNHEALTHY_THRESHOLD = 3 + +# The time in seconds that the Serve client waits before rechecking deployment state +CLIENT_POLLING_INTERVAL_S = 1.0 + +# The time in seconds that the Serve client waits before checking if +# deployment has been created +CLIENT_CHECK_CREATION_POLLING_INTERVAL_S = 0.1 + +# Timeout for GCS internal KV service +RAY_SERVE_KV_TIMEOUT_S = get_env_float_positive("RAY_SERVE_KV_TIMEOUT_S", None) + +# Timeout for GCS RPC request +RAY_GCS_RPC_TIMEOUT_S = 3.0 + +# Maximum duration to wait until broadcasting a long poll update if there are +# still replicas in the RECOVERING state. +RECOVERING_LONG_POLL_BROADCAST_TIMEOUT_S = 10.0 + +# Minimum duration to wait until broadcasting model IDs. +PUSH_MULTIPLEXED_MODEL_IDS_INTERVAL_S = 0.1 + +# Deprecation message for V1 migrations. +MIGRATION_MESSAGE = ( + "See https://docs.ray.io/en/latest/serve/index.html for more information." +) + +# Environment variable name for to specify the encoding of the log messages +RAY_SERVE_LOG_ENCODING = get_env_str("RAY_SERVE_LOG_ENCODING", "TEXT") + +# Jsonify the log messages. This constant is deprecated and will be removed in the +# future. Use RAY_SERVE_LOG_ENCODING or 'LoggingConfig' to enable json format. +RAY_SERVE_ENABLE_JSON_LOGGING = get_env_bool("RAY_SERVE_ENABLE_JSON_LOGGING", "0") + +# Setting RAY_SERVE_LOG_TO_STDERR=0 will disable logging to the stdout and stderr. +# Also, redirect them to serve's log files. +RAY_SERVE_LOG_TO_STDERR = get_env_bool("RAY_SERVE_LOG_TO_STDERR", "1") + +# Logging format attributes +SERVE_LOG_REQUEST_ID = "request_id" +SERVE_LOG_ROUTE = "route" +SERVE_LOG_APPLICATION = "application" +SERVE_LOG_DEPLOYMENT = "deployment" +SERVE_LOG_REPLICA = "replica" +SERVE_LOG_COMPONENT = "component_name" +SERVE_LOG_COMPONENT_ID = "component_id" +SERVE_LOG_MESSAGE = "message" +# This is a reserved for python logging module attribute, it should not be changed. +SERVE_LOG_LEVEL_NAME = "levelname" +SERVE_LOG_TIME = "asctime" + +# Logging format with record key to format string dict +SERVE_LOG_RECORD_FORMAT = { + SERVE_LOG_REQUEST_ID: "%(request_id)s", + SERVE_LOG_APPLICATION: "%(application)s", + SERVE_LOG_MESSAGE: "-- %(message)s", + SERVE_LOG_LEVEL_NAME: "%(levelname)s", + SERVE_LOG_TIME: "%(asctime)s", +} + +# There are some attributes that we only use internally or don't provide values to the +# users. Adding to this set will remove them from structured logs. +SERVE_LOG_UNWANTED_ATTRS = { + "serve_access_log", + "task_id", + "job_id", + "skip_context_filter", +} + +RAY_SERVE_HTTP_KEEP_ALIVE_TIMEOUT_S = get_env_int_non_negative( + "RAY_SERVE_HTTP_KEEP_ALIVE_TIMEOUT_S", 0 +) + +RAY_SERVE_REQUEST_PROCESSING_TIMEOUT_S = ( + get_env_float_non_negative( + "RAY_SERVE_REQUEST_PROCESSING_TIMEOUT_S", + get_env_float_non_negative("SERVE_REQUEST_PROCESSING_TIMEOUT_S", 0.0), + ) + or None +) + +SERVE_LOG_EXTRA_FIELDS = "ray_serve_extra_fields" + +# Serve HTTP request header key for routing requests. +SERVE_MULTIPLEXED_MODEL_ID = "serve_multiplexed_model_id" + +# HTTP request ID +SERVE_HTTP_REQUEST_ID_HEADER = "x-request-id" + +# Feature flag to turn on node locality routing for proxies. On by default. +RAY_SERVE_PROXY_PREFER_LOCAL_NODE_ROUTING = get_env_bool( + "RAY_SERVE_PROXY_PREFER_LOCAL_NODE_ROUTING", "1" +) + +# Feature flag to turn on AZ locality routing for proxies. On by default. +RAY_SERVE_PROXY_PREFER_LOCAL_AZ_ROUTING = get_env_bool( + "RAY_SERVE_PROXY_PREFER_LOCAL_AZ_ROUTING", "1" +) + +# Serve HTTP proxy callback import path. +RAY_SERVE_HTTP_PROXY_CALLBACK_IMPORT_PATH = get_env_str( + "RAY_SERVE_HTTP_PROXY_CALLBACK_IMPORT_PATH", None +) +# Serve controller callback import path. +RAY_SERVE_CONTROLLER_CALLBACK_IMPORT_PATH = get_env_str( + "RAY_SERVE_CONTROLLER_CALLBACK_IMPORT_PATH", None +) + +# Maximum timeout allowed for record_autoscaling_stats to run. +RAY_SERVE_RECORD_AUTOSCALING_STATS_TIMEOUT_S = get_env_float( + "RAY_SERVE_RECORD_AUTOSCALING_STATS_TIMEOUT_S", 10.0 +) + +# How often autoscaling metrics are recorded on Serve replicas. +RAY_SERVE_REPLICA_AUTOSCALING_METRIC_RECORD_INTERVAL_S = get_env_float( + "RAY_SERVE_REPLICA_AUTOSCALING_METRIC_RECORD_INTERVAL_S", 0.5 +) + +# Replica autoscaling metrics push interval. +RAY_SERVE_REPLICA_AUTOSCALING_METRIC_PUSH_INTERVAL_S = get_env_float( + "RAY_SERVE_REPLICA_AUTOSCALING_METRIC_PUSH_INTERVAL_S", 10.0 +) + +# How often autoscaling metrics are recorded on Serve handles. +RAY_SERVE_HANDLE_AUTOSCALING_METRIC_RECORD_INTERVAL_S = get_env_float( + "RAY_SERVE_HANDLE_AUTOSCALING_METRIC_RECORD_INTERVAL_S", 0.5 +) + +# Handle autoscaling metrics push interval. (This interval will affect the cold start time period) +RAY_SERVE_HANDLE_AUTOSCALING_METRIC_PUSH_INTERVAL_S = get_env_float( + "RAY_SERVE_HANDLE_AUTOSCALING_METRIC_PUSH_INTERVAL_S", + # Legacy env var for RAY_SERVE_HANDLE_AUTOSCALING_METRIC_PUSH_INTERVAL_S + get_env_float("RAY_SERVE_HANDLE_METRIC_PUSH_INTERVAL_S", 10.0), +) + +# Serve multiplexed matching timeout. +# This is the timeout for the matching process of multiplexed requests. To avoid +# thundering herd problem, the timeout value will be randomized between this value +# and this value * 2. The unit is second. +# If the matching process takes longer than the timeout, the request will be +# fallen to the default routing strategy. +RAY_SERVE_MULTIPLEXED_MODEL_ID_MATCHING_TIMEOUT_S = get_env_float_non_negative( + "RAY_SERVE_MULTIPLEXED_MODEL_ID_MATCHING_TIMEOUT_S", 1.0 +) + +# Enable memray in all Serve actors. +RAY_SERVE_ENABLE_MEMORY_PROFILING = get_env_bool( + "RAY_SERVE_ENABLE_MEMORY_PROFILING", "0" +) + +# Max value allowed for max_replicas_per_node option. +# TODO(jjyao) the <= 100 limitation is an artificial one +# and is due to the fact that Ray core only supports resource +# precision up to 0.0001. +# This limitation should be lifted in the long term. +MAX_REPLICAS_PER_NODE_MAX_VALUE = 100 + +# Argument name for passing in the gRPC context into a replica. +GRPC_CONTEXT_ARG_NAME = "grpc_context" + +# Whether or not to forcefully kill replicas that fail health checks. +RAY_SERVE_FORCE_STOP_UNHEALTHY_REPLICAS = get_env_bool( + "RAY_SERVE_FORCE_STOP_UNHEALTHY_REPLICAS", "0" +) + +# Initial deadline for queue length responses in the router. +RAY_SERVE_QUEUE_LENGTH_RESPONSE_DEADLINE_S = get_env_float( + "RAY_SERVE_QUEUE_LENGTH_RESPONSE_DEADLINE_S", 0.1 +) + +# Maximum deadline for queue length responses in the router (in backoff). +RAY_SERVE_MAX_QUEUE_LENGTH_RESPONSE_DEADLINE_S = get_env_float( + "RAY_SERVE_MAX_QUEUE_LENGTH_RESPONSE_DEADLINE_S", 1.0 +) + +# Length of time to respect entries in the queue length cache when routing requests. +RAY_SERVE_QUEUE_LENGTH_CACHE_TIMEOUT_S = get_env_float_non_negative( + "RAY_SERVE_QUEUE_LENGTH_CACHE_TIMEOUT_S", 10.0 +) + +# Backoff seconds when choosing router failed, backoff time is calculated as +# initial_backoff_s * backoff_multiplier ** attempt. +# The default backoff time is [0, 0.025, 0.05, 0.1, 0.2, 0.4, 0.5, 0.5 ... ]. +RAY_SERVE_ROUTER_RETRY_INITIAL_BACKOFF_S = get_env_float( + "RAY_SERVE_ROUTER_RETRY_INITIAL_BACKOFF_S", 0.025 +) +RAY_SERVE_ROUTER_RETRY_BACKOFF_MULTIPLIER = get_env_int( + "RAY_SERVE_ROUTER_RETRY_BACKOFF_MULTIPLIER", 2 +) +RAY_SERVE_ROUTER_RETRY_MAX_BACKOFF_S = get_env_float( + "RAY_SERVE_ROUTER_RETRY_MAX_BACKOFF_S", 0.5 +) + +# The default autoscaling policy to use if none is specified. +DEFAULT_AUTOSCALING_POLICY_NAME = ( + "ray.serve.autoscaling_policy:default_autoscaling_policy" +) + +# Feature flag to enable collecting all queued and ongoing request +# metrics at handles instead of replicas. ON by default. +RAY_SERVE_COLLECT_AUTOSCALING_METRICS_ON_HANDLE = get_env_bool( + "RAY_SERVE_COLLECT_AUTOSCALING_METRICS_ON_HANDLE", "1" +) + +RAY_SERVE_MIN_HANDLE_METRICS_TIMEOUT_S = get_env_float_non_negative( + "RAY_SERVE_MIN_HANDLE_METRICS_TIMEOUT_S", 10.0 +) + +# Feature flag to always run a proxy on the head node even if it has no replicas. +RAY_SERVE_ALWAYS_RUN_PROXY_ON_HEAD_NODE = get_env_bool( + "RAY_SERVE_ALWAYS_RUN_PROXY_ON_HEAD_NODE", "1" +) + +# Default is 2GiB, the max for a signed int. +RAY_SERVE_GRPC_MAX_MESSAGE_SIZE = get_env_int( + "RAY_SERVE_GRPC_MAX_MESSAGE_SIZE", (2 * 1024 * 1024 * 1024) - 1 +) + +# Default options passed when constructing gRPC servers. +DEFAULT_GRPC_SERVER_OPTIONS = [ + ("grpc.max_send_message_length", RAY_SERVE_GRPC_MAX_MESSAGE_SIZE), + ("grpc.max_receive_message_length", RAY_SERVE_GRPC_MAX_MESSAGE_SIZE), +] + +# Timeout for gracefully shutting down metrics pusher, e.g. in routers or replicas +METRICS_PUSHER_GRACEFUL_SHUTDOWN_TIMEOUT_S = 10 + +# Feature flag to set `enable_task_events=True` on Serve-managed actors. +RAY_SERVE_ENABLE_TASK_EVENTS = get_env_bool("RAY_SERVE_ENABLE_TASK_EVENTS", "0") + +# Use compact instead of spread scheduling strategy +RAY_SERVE_USE_COMPACT_SCHEDULING_STRATEGY = get_env_bool( + "RAY_SERVE_USE_COMPACT_SCHEDULING_STRATEGY", "0" +) + +# Comma-separated list of custom resources prioritized in scheduling. Sorted from highest to lowest priority. +# Example: "customx,customy" +RAY_SERVE_HIGH_PRIORITY_CUSTOM_RESOURCES: List[str] = str_to_list( + get_env_str("RAY_SERVE_HIGH_PRIORITY_CUSTOM_RESOURCES", "") +) + +# Feature flag to always override local_testing_mode to True in serve.run. +# This is used for internal testing to avoid passing the flag to every invocation. +RAY_SERVE_FORCE_LOCAL_TESTING_MODE = get_env_bool( + "RAY_SERVE_FORCE_LOCAL_TESTING_MODE", "0" +) + +# Run sync methods defined in the replica in a thread pool by default. +RAY_SERVE_RUN_SYNC_IN_THREADPOOL = get_env_bool("RAY_SERVE_RUN_SYNC_IN_THREADPOOL", "0") + +RAY_SERVE_RUN_SYNC_IN_THREADPOOL_WARNING = ( + "Calling sync method '{method_name}' directly on the " + "asyncio loop. In a future version, sync methods will be run in a " + "threadpool by default. Ensure your sync methods are thread safe " + "or keep the existing behavior by making them `async def`. Opt " + "into the new behavior by setting " + "RAY_SERVE_RUN_SYNC_IN_THREADPOOL=1." +) + +# Feature flag to turn off GC optimizations in the proxy (in case there is a +# memory leak or negative performance impact). +RAY_SERVE_ENABLE_PROXY_GC_OPTIMIZATIONS = get_env_bool( + "RAY_SERVE_ENABLE_PROXY_GC_OPTIMIZATIONS", "1" +) + +# Used for gc.set_threshold() when proxy GC optimizations are enabled. +RAY_SERVE_PROXY_GC_THRESHOLD = get_env_int("RAY_SERVE_PROXY_GC_THRESHOLD", 700) + +# Interval at which cached metrics will be exported using the Ray metric API. +# Set to `0` to disable caching entirely. +RAY_SERVE_METRICS_EXPORT_INTERVAL_MS = get_env_int( + "RAY_SERVE_METRICS_EXPORT_INTERVAL_MS", 100 +) + +# The default request router class to use if none is specified. +DEFAULT_REQUEST_ROUTER_PATH = ( + "ray.serve._private.request_router:PowerOfTwoChoicesRequestRouter" +) + +# The default request routing period to use if none is specified. +DEFAULT_REQUEST_ROUTING_STATS_PERIOD_S = 10 + +# The default request routing timeout to use if none is specified. +DEFAULT_REQUEST_ROUTING_STATS_TIMEOUT_S = 30 + +# Name of deployment request routing stats method implemented by user. +REQUEST_ROUTING_STATS_METHOD = "record_routing_stats" + +# By default, we run user code in a separate event loop. +# This flag can be set to 0 to run user code in the same event loop as the +# replica's main event loop. +RAY_SERVE_RUN_USER_CODE_IN_SEPARATE_THREAD = get_env_bool( + "RAY_SERVE_RUN_USER_CODE_IN_SEPARATE_THREAD", "1" +) + +# By default, we run the router in a separate event loop. +# This flag can be set to 0 to run the router in the same event loop as the +# replica's main event loop. +RAY_SERVE_RUN_ROUTER_IN_SEPARATE_LOOP = get_env_bool( + "RAY_SERVE_RUN_ROUTER_IN_SEPARATE_LOOP", "1" +) + +# The default buffer size for request path logs. Setting to 1 will ensure +# logs are flushed to file handler immediately, otherwise it will be buffered +# and flushed to file handler when the buffer is full or when there is a log +# line with level ERROR. +RAY_SERVE_REQUEST_PATH_LOG_BUFFER_SIZE = get_env_int( + "RAY_SERVE_REQUEST_PATH_LOG_BUFFER_SIZE", 1 +) + +# Feature flag to fail the deployment if the rank is not set. +# TODO (abrar): Remove this flag after the feature is stable. +RAY_SERVE_FAIL_ON_RANK_ERROR = get_env_bool("RAY_SERVE_FAIL_ON_RANK_ERROR", "0") + +# The message to return when the replica is healthy. +HEALTHY_MESSAGE = "success" + +# If throughput optimized Ray Serve is enabled, set the following constants. +# This should be at the end. +RAY_SERVE_THROUGHPUT_OPTIMIZED = get_env_bool("RAY_SERVE_THROUGHPUT_OPTIMIZED", "0") +if RAY_SERVE_THROUGHPUT_OPTIMIZED: + RAY_SERVE_RUN_USER_CODE_IN_SEPARATE_THREAD = get_env_bool( + "RAY_SERVE_RUN_USER_CODE_IN_SEPARATE_THREAD", "0" + ) + RAY_SERVE_REQUEST_PATH_LOG_BUFFER_SIZE = get_env_int( + "RAY_SERVE_REQUEST_PATH_LOG_BUFFER_SIZE", 1000 + ) + RAY_SERVE_RUN_ROUTER_IN_SEPARATE_LOOP = get_env_bool( + "RAY_SERVE_RUN_ROUTER_IN_SEPARATE_LOOP", "0" + ) + RAY_SERVE_LOG_TO_STDERR = get_env_bool("RAY_SERVE_LOG_TO_STDERR", "0") + +# The maximum allowed RPC latency in milliseconds. +# This is used to detect and warn about long RPC latencies +# between the controller and the replicas. +RAY_SERVE_RPC_LATENCY_WARNING_THRESHOLD_MS = 2000 + +# Feature flag to aggregate metrics at the controller instead of the replicas or handles. +RAY_SERVE_AGGREGATE_METRICS_AT_CONTROLLER = get_env_bool( + "RAY_SERVE_AGGREGATE_METRICS_AT_CONTROLLER", "0" +) diff --git a/lib/python3.12/site-packages/ray/serve/_private/constants_utils.py b/lib/python3.12/site-packages/ray/serve/_private/constants_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..019df87d86b8c0cc33f78d5cf45c9bfa4d788471 --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/_private/constants_utils.py @@ -0,0 +1,291 @@ +import os +import warnings +from typing import Callable, List, Optional, Type, TypeVar + + +def str_to_list(s: str) -> List[str]: + """Return a list from a comma-separated string. + + Trims whitespace and skips empty entries. + """ + return [part for part in (part.strip() for part in s.split(",")) if part] + + +def parse_latency_buckets(bucket_str: str, default_buckets: List[float]) -> List[float]: + """Parse a comma-separated string of latency bucket values. + + Args: + bucket_str: A comma-separated string of positive numbers in ascending order. + default_buckets: Default bucket values to use if bucket_str is empty. + + Returns: + A list of parsed float values. + + Raises: + ValueError: If the format is invalid or values don't meet requirements. + """ + if bucket_str.strip() == "": + return default_buckets + try: + # Convert string to list of floats + buckets = [float(x.strip()) for x in bucket_str.split(",")] + + if not buckets: + raise ValueError("Empty bucket list") + if any(x <= 0 for x in buckets): + raise ValueError("Bucket values must be positive") + if sorted(set(buckets)) != buckets: + raise ValueError("Bucket values must be in strictly ascending order") + + return buckets + except Exception as e: + raise ValueError( + f"Invalid format for `{bucket_str}`. " + f"Expected comma-separated positive numbers in ascending order. Error: {str(e)}" + ) from e + + +T = TypeVar("T") + +# todo: remove for the '3.0.0' release. +_wrong_names_white_list = { + "MAX_DEPLOYMENT_CONSTRUCTOR_RETRY_COUNT", + "MAX_PER_REPLICA_RETRY_COUNT", + "REQUEST_LATENCY_BUCKETS_MS", + "MODEL_LOAD_LATENCY_BUCKETS_MS", + "MAX_CACHED_HANDLES", + "CONTROLLER_MAX_CONCURRENCY", + "SERVE_REQUEST_PROCESSING_TIMEOUT_S", +} + + +def _validate_name(name: str) -> None: + """Validate Ray Serve environment variable name.""" + required_prefix = "RAY_SERVE_" + + if not name.startswith(required_prefix): + if name in _wrong_names_white_list: + return + + raise ValueError( + f"Got unexpected environment variable name `{name}`! " + f"Ray Serve environment variables require prefix `{required_prefix}`. " + ) + + +def _get_env_value( + name: str, + default: Optional[T], + value_type: Type[T], + validation_func: Optional[Callable[[T], bool]] = None, + expected_value_description: Optional[str] = None, +) -> Optional[T]: + """Get environment variable with type conversion and validation. + + This function retrieves an environment variable, converts it to the specified type, + and optionally validates the converted value. + + Args: + name: The name of the environment variable. + default: Default value to use if the environment variable is not set. + If None, the function will return None without validation. + value_type: Type to convert the environment variable value to (e.g., int, float, str). + validation_func: Optional function that takes the converted value and returns + a boolean indicating whether the value is valid. + expected_value_description: Description of the expected value characteristics + (e.g., "positive", "non-negative") used in error messages. + Optional, expected only if validation_func is provided. + + Returns: + The environment variable value converted to the specified type and validated, + or the default value if the environment variable is not set. + + Raises: + ValueError: If the environment variable value cannot be converted to the specified + type, or if it fails the optional validation check. Also, if name validation fails. + """ + _validate_name(name) + + explicitly_defined_value = os.environ.get(name) + if explicitly_defined_value is None: + if default is None: + return None + else: + raw = default + else: + _deprecation_warning(name) + raw = explicitly_defined_value + + try: + value = value_type(raw) + except ValueError as e: + raise ValueError( + f"Environment variable `{name}` value `{raw}` cannot be converted to `{value_type.__name__}`!" + ) from e + + if validation_func and not validation_func(value): + raise ValueError( + f"Got unexpected value `{value}` for `{name}` environment variable! " + f"Expected {expected_value_description} `{value_type.__name__}`." + ) + + return value + + +def get_env_int(name: str, default: Optional[int]) -> Optional[int]: + """Get environment variable as an integer. + + Args: + name: The name of the environment variable. + default: Default value to use if the environment variable is not set. + + Returns: + The environment variable value as an integer. + + Raises: + ValueError: If the value cannot be converted to an integer. + """ + return _get_env_value(name, default, int) + + +def get_env_int_positive(name: str, default: Optional[int]) -> Optional[int]: + """Get environment variable as a positive integer. + + Args: + name: The name of the environment variable. + default: Default value to use if the environment variable is not set. + + Returns: + The environment variable value as a positive integer. + + Raises: + ValueError: If the value cannot be converted to an integer or is not positive. + """ + return _get_env_value(name, default, int, lambda x: x > 0, "positive") + + +def get_env_int_non_negative(name: str, default: Optional[int]) -> Optional[int]: + """Get environment variable as a non-negative integer. + + Args: + name: The name of the environment variable. + default: Default value to use if the environment variable is not set. + + Returns: + The environment variable value as a non-negative integer. + + Raises: + ValueError: If the value cannot be converted to an integer or is negative. + """ + return _get_env_value(name, default, int, lambda x: x >= 0, "non negative") + + +def get_env_float(name: str, default: Optional[float]) -> Optional[float]: + """Get environment variable as a float. + + Args: + name: The name of the environment variable. + default: Default value to use if the environment variable is not set. + + Returns: + The environment variable value as a float. + + Raises: + ValueError: If the value cannot be converted to a float. + """ + return _get_env_value(name, default, float) + + +def get_env_float_positive(name: str, default: Optional[float]) -> Optional[float]: + """Get environment variable as a positive float. + + Args: + name: The name of the environment variable. + default: Default value to use if the environment variable is not set. + + Returns: + The environment variable value as a positive float. + + Raises: + ValueError: If the value cannot be converted to a float or is not positive. + """ + return _get_env_value(name, default, float, lambda x: x > 0, "positive") + + +def get_env_float_non_negative(name: str, default: Optional[float]) -> Optional[float]: + """Get environment variable as a non-negative float. + + Args: + name: The name of the environment variable. + default: Default value to use if the environment variable is not set. + + Returns: + The environment variable value as a non-negative float. + + Raises: + ValueError: If the value cannot be converted to a float or is negative. + """ + return _get_env_value(name, default, float, lambda x: x >= 0, "non negative") + + +def get_env_str(name: str, default: Optional[str]) -> Optional[str]: + """Get environment variable as a string. + + Args: + name: The name of the environment variable. + default: Default value to use if the environment variable is not set. + + Returns: + The environment variable value as a string. + Returns `None` if default is `None` and value not found. + """ + return _get_env_value(name, default, str) + + +def get_env_bool(name: str, default: str) -> bool: + """Get environment variable as a boolean. + + Environment variable values of "1" are interpreted as True, all others as False. + + Args: + name: The name of the environment variable. + default: Default value to use if the environment variable is not set. + Expects "0" or "1". + + Returns: + True if the environment variable value is "1", False otherwise. + """ + env_value_str = _get_env_value(name, default, str) + return env_value_str == "1" + + +def _deprecation_warning(name: str) -> None: + """Log replacement warning for wrong or legacy environment variables. + + TODO: remove this function for the '3.0.0' release. + + :param name: environment variable name + """ + + def get_new_name(name: str) -> str: + if name == "RAY_SERVE_HANDLE_METRIC_PUSH_INTERVAL_S": + return "RAY_SERVE_HANDLE_AUTOSCALING_METRIC_PUSH_INTERVAL_S" + elif name == "SERVE_REQUEST_PROCESSING_TIMEOUT_S": + return "RAY_SERVE_REQUEST_PROCESSING_TIMEOUT_S" + else: + return f"{required_prefix}{name}" + + change_version = "3.0.0" + required_prefix = "RAY_SERVE_" + + if ( + name in _wrong_names_white_list + or name == "RAY_SERVE_HANDLE_METRIC_PUSH_INTERVAL_S" + ): + new_name = get_new_name(name) + warnings.warn( + f"Starting from version `{change_version}` environment variable " + f"`{name}` will be deprecated. Please use `{new_name}` instead.", + FutureWarning, + stacklevel=4, + ) diff --git a/lib/python3.12/site-packages/ray/serve/_private/controller.py b/lib/python3.12/site-packages/ray/serve/_private/controller.py new file mode 100644 index 0000000000000000000000000000000000000000..ca59cc03db03f12d48f33101b1bcddddb47ced75 --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/_private/controller.py @@ -0,0 +1,1250 @@ +import asyncio +import logging +import os +import pickle +import time +from typing import ( + Any, + Dict, + Iterable, + List, + Optional, + Tuple, + Union, +) + +import ray +from ray._common.network_utils import build_address +from ray._common.utils import run_background_task +from ray._raylet import GcsClient +from ray.actor import ActorHandle +from ray.serve._private.application_state import ApplicationStateManager, StatusOverview +from ray.serve._private.autoscaling_state import AutoscalingStateManager +from ray.serve._private.common import ( + DeploymentID, + HandleMetricReport, + NodeId, + ReplicaMetricReport, + RequestProtocol, + RequestRoutingInfo, + RunningReplicaInfo, + TargetCapacityDirection, +) +from ray.serve._private.config import DeploymentConfig +from ray.serve._private.constants import ( + CONTROL_LOOP_INTERVAL_S, + RAY_SERVE_CONTROLLER_CALLBACK_IMPORT_PATH, + RAY_SERVE_RPC_LATENCY_WARNING_THRESHOLD_MS, + RECOVERING_LONG_POLL_BROADCAST_TIMEOUT_S, + SERVE_CONTROLLER_NAME, + SERVE_DEFAULT_APP_NAME, + SERVE_LOGGER_NAME, + SERVE_NAMESPACE, + SERVE_ROOT_URL_ENV_KEY, +) +from ray.serve._private.default_impl import create_cluster_node_info_cache +from ray.serve._private.deployment_info import DeploymentInfo +from ray.serve._private.deployment_state import DeploymentStateManager +from ray.serve._private.endpoint_state import EndpointState +from ray.serve._private.grpc_util import set_proxy_default_grpc_options +from ray.serve._private.http_util import ( + configure_http_options_with_defaults, +) +from ray.serve._private.logging_utils import ( + configure_component_logger, + configure_component_memory_profiler, + get_component_logger_file_path, +) +from ray.serve._private.long_poll import LongPollHost, LongPollNamespace +from ray.serve._private.proxy_state import ProxyStateManager +from ray.serve._private.storage.kv_store import RayInternalKVStore +from ray.serve._private.usage import ServeUsageTag +from ray.serve._private.utils import ( + call_function_from_import_path, + get_all_live_placement_group_names, + get_head_node_id, + is_grpc_enabled, +) +from ray.serve.config import HTTPOptions, ProxyLocation, gRPCOptions +from ray.serve.generated.serve_pb2 import ( + ActorNameList, + DeploymentArgs, + DeploymentRoute, + EndpointInfo as EndpointInfoProto, + EndpointSet, +) +from ray.serve.schema import ( + APIType, + ApplicationDetails, + DeploymentDetails, + HTTPOptionsSchema, + LoggingConfig, + ProxyDetails, + ServeActorDetails, + ServeApplicationSchema, + ServeDeploySchema, + ServeInstanceDetails, + TargetGroup, + gRPCOptionsSchema, +) +from ray.util import metrics + +logger = logging.getLogger(SERVE_LOGGER_NAME) + +# Used for testing purposes only. If this is set, the controller will crash +# after writing each checkpoint with the specified probability. +_CRASH_AFTER_CHECKPOINT_PROBABILITY = 0 + +CONFIG_CHECKPOINT_KEY = "serve-app-config-checkpoint" +LOGGING_CONFIG_CHECKPOINT_KEY = "serve-logging-config-checkpoint" + + +class ServeController: + """Responsible for managing the state of the serving system. + + The controller implements fault tolerance by persisting its state in + a new checkpoint each time a state change is made. If the actor crashes, + the latest checkpoint is loaded and the state is recovered. Checkpoints + are written/read using a provided KV-store interface. + + All hard state in the system is maintained by this actor and persisted via + these checkpoints. Soft state required by other components is fetched by + those actors from this actor on startup and updates are pushed out from + this actor. + + All other actors started by the controller are named, detached actors + so they will not fate share with the controller if it crashes. + + The following guarantees are provided for state-changing calls to the + controller: + - If the call succeeds, the change was made and will be reflected in + the system even if the controller or other actors die unexpectedly. + - If the call fails, the change may have been made but isn't guaranteed + to have been. The client should retry in this case. Note that this + requires all implementations here to be idempotent. + """ + + async def __init__( + self, + *, + http_options: HTTPOptions, + global_logging_config: LoggingConfig, + grpc_options: Optional[gRPCOptions] = None, + ): + self._controller_node_id = ray.get_runtime_context().get_node_id() + assert ( + self._controller_node_id == get_head_node_id() + ), "Controller must be on the head node." + + self.ray_worker_namespace = ray.get_runtime_context().namespace + self.gcs_client = GcsClient(address=ray.get_runtime_context().gcs_address) + kv_store_namespace = f"ray-serve-{self.ray_worker_namespace}" + self.kv_store = RayInternalKVStore(kv_store_namespace, self.gcs_client) + + self.long_poll_host = LongPollHost() + self.done_recovering_event = asyncio.Event() + + # Try to read config from checkpoint + # logging config from checkpoint take precedence over the one passed in + # the constructor. + self.global_logging_config = None + log_config_checkpoint = self.kv_store.get(LOGGING_CONFIG_CHECKPOINT_KEY) + if log_config_checkpoint is not None: + global_logging_config = pickle.loads(log_config_checkpoint) + self.reconfigure_global_logging_config(global_logging_config) + + configure_component_memory_profiler( + component_name="controller", component_id=str(os.getpid()) + ) + + if RAY_SERVE_CONTROLLER_CALLBACK_IMPORT_PATH: + logger.info( + "Calling user-provided callback from import path " + f"{RAY_SERVE_CONTROLLER_CALLBACK_IMPORT_PATH}." + ) + call_function_from_import_path(RAY_SERVE_CONTROLLER_CALLBACK_IMPORT_PATH) + + # Used to read/write checkpoints. + self.cluster_node_info_cache = create_cluster_node_info_cache(self.gcs_client) + self.cluster_node_info_cache.update() + + # Configure proxy default HTTP and gRPC options. + self.proxy_state_manager = ProxyStateManager( + http_options=configure_http_options_with_defaults(http_options), + head_node_id=self._controller_node_id, + cluster_node_info_cache=self.cluster_node_info_cache, + logging_config=self.global_logging_config, + grpc_options=set_proxy_default_grpc_options(grpc_options), + ) + # We modify the HTTP and gRPC options above, so delete them to avoid + del http_options, grpc_options + + self.endpoint_state = EndpointState(self.kv_store, self.long_poll_host) + + # Fetch all running actors in current cluster as source of current + # replica state for controller failure recovery + all_current_actors = ray.util.list_named_actors(all_namespaces=True) + all_serve_actor_names = [ + actor["name"] + for actor in all_current_actors + if actor["namespace"] == SERVE_NAMESPACE + ] + + self.autoscaling_state_manager = AutoscalingStateManager() + self.deployment_state_manager = DeploymentStateManager( + self.kv_store, + self.long_poll_host, + all_serve_actor_names, + get_all_live_placement_group_names(), + self.cluster_node_info_cache, + self.autoscaling_state_manager, + ) + + # Manage all applications' state + self.application_state_manager = ApplicationStateManager( + self.deployment_state_manager, + self.autoscaling_state_manager, + self.endpoint_state, + self.kv_store, + self.global_logging_config, + ) + + # Controller actor details + self._actor_details = ServeActorDetails( + node_id=ray.get_runtime_context().get_node_id(), + node_ip=ray.util.get_node_ip_address(), + node_instance_id=ray.util.get_node_instance_id(), + actor_id=ray.get_runtime_context().get_actor_id(), + actor_name=SERVE_CONTROLLER_NAME, + worker_id=ray.get_runtime_context().get_worker_id(), + log_file_path=get_component_logger_file_path(), + ) + self._shutting_down = False + self._shutdown_event = asyncio.Event() + self._shutdown_start_time = None + + self._create_control_loop_metrics() + run_background_task(self.run_control_loop()) + + # The target capacity percentage for all deployments across the cluster. + self._target_capacity: Optional[float] = None + self._target_capacity_direction: Optional[TargetCapacityDirection] = None + self._recover_state_from_checkpoint() + + # Nodes where proxy actors should run. + self._proxy_nodes = set() + self._update_proxy_nodes() + + def reconfigure_global_logging_config(self, global_logging_config: LoggingConfig): + if ( + self.global_logging_config + and self.global_logging_config == global_logging_config + ): + return + self.kv_store.put( + LOGGING_CONFIG_CHECKPOINT_KEY, pickle.dumps(global_logging_config) + ) + self.global_logging_config = global_logging_config + + self.long_poll_host.notify_changed( + {LongPollNamespace.GLOBAL_LOGGING_CONFIG: global_logging_config} + ) + configure_component_logger( + component_name="controller", + component_id=str(os.getpid()), + logging_config=global_logging_config, + ) + + logger.info( + f"Controller starting (version='{ray.__version__}').", + extra={"log_to_stderr": False}, + ) + logger.debug( + "Configure the serve controller logger " + f"with logging config: {self.global_logging_config}" + ) + + def check_alive(self) -> None: + """No-op to check if this controller is alive.""" + return + + def get_pid(self) -> int: + return os.getpid() + + def record_autoscaling_metrics_from_replica( + self, replica_metric_report: ReplicaMetricReport + ): + latency = time.time() - replica_metric_report.timestamp + latency_ms = latency * 1000 + if latency_ms > RAY_SERVE_RPC_LATENCY_WARNING_THRESHOLD_MS: + logger.warning( + f"Received autoscaling metrics from replica {replica_metric_report.replica_id} with timestamp {replica_metric_report.timestamp} " + f"which is {latency_ms}ms ago. " + f"This is greater than the warning threshold RPC latency of {RAY_SERVE_RPC_LATENCY_WARNING_THRESHOLD_MS}ms. " + "This may indicate a performance issue with the controller try increasing the RAY_SERVE_RPC_LATENCY_WARNING_THRESHOLD_MS environment variable." + ) + self.autoscaling_state_manager.record_request_metrics_for_replica( + replica_metric_report + ) + + def record_autoscaling_metrics_from_handle( + self, handle_metric_report: HandleMetricReport + ): + latency = time.time() - handle_metric_report.timestamp + latency_ms = latency * 1000 + if latency_ms > RAY_SERVE_RPC_LATENCY_WARNING_THRESHOLD_MS: + logger.warning( + f"Received autoscaling metrics from handle {handle_metric_report.handle_id} for deployment {handle_metric_report.deployment_id} with timestamp {handle_metric_report.timestamp} " + f"which is {latency_ms}ms ago. " + f"This is greater than the warning threshold RPC latency of {RAY_SERVE_RPC_LATENCY_WARNING_THRESHOLD_MS}ms. " + "This may indicate a performance issue with the controller try increasing the RAY_SERVE_RPC_LATENCY_WARNING_THRESHOLD_MS environment variable." + ) + self.autoscaling_state_manager.record_request_metrics_for_handle( + handle_metric_report + ) + + def _get_total_num_requests_for_deployment_for_testing( + self, deployment_id: DeploymentID + ): + return self.autoscaling_state_manager.get_total_num_requests_for_deployment( + deployment_id + ) + + def _get_metrics_for_deployment_for_testing(self, deployment_id: DeploymentID): + return self.autoscaling_state_manager.get_metrics_for_deployment(deployment_id) + + def _dump_replica_states_for_testing(self, deployment_id: DeploymentID): + return self.deployment_state_manager._deployment_states[deployment_id]._replicas + + def _stop_one_running_replica_for_testing(self, deployment_id): + self.deployment_state_manager._deployment_states[ + deployment_id + ]._stop_one_running_replica_for_testing() + + async def listen_for_change(self, keys_to_snapshot_ids: Dict[str, int]): + """Proxy long pull client's listen request. + + Args: + keys_to_snapshot_ids (Dict[str, int]): Snapshot IDs are used to + determine whether or not the host should immediately return the + data or wait for the value to be changed. + """ + if not self.done_recovering_event.is_set(): + await self.done_recovering_event.wait() + + return await self.long_poll_host.listen_for_change(keys_to_snapshot_ids) + + async def listen_for_change_java(self, keys_to_snapshot_ids_bytes: bytes): + """Proxy long pull client's listen request. + + Args: + keys_to_snapshot_ids_bytes (Dict[str, int]): the protobuf bytes of + keys_to_snapshot_ids (Dict[str, int]). + """ + if not self.done_recovering_event.is_set(): + await self.done_recovering_event.wait() + + return await self.long_poll_host.listen_for_change_java( + keys_to_snapshot_ids_bytes + ) + + def get_all_endpoints(self) -> Dict[DeploymentID, Dict[str, Any]]: + """Returns a dictionary of deployment name to config.""" + return self.endpoint_state.get_endpoints() + + def get_all_endpoints_java(self) -> bytes: + """Returns a dictionary of deployment name to config.""" + endpoints = self.get_all_endpoints() + # NOTE(zcin): Java only supports 1.x deployments, so only return + # a dictionary of deployment name -> endpoint info + data = { + endpoint_tag.name: EndpointInfoProto(route=endpoint_dict["route"]) + for endpoint_tag, endpoint_dict in endpoints.items() + } + return EndpointSet(endpoints=data).SerializeToString() + + def get_proxies(self) -> Dict[NodeId, ActorHandle]: + """Returns a dictionary of node ID to proxy actor handles.""" + if self.proxy_state_manager is None: + return {} + return self.proxy_state_manager.get_proxy_handles() + + def get_proxy_names(self) -> bytes: + """Returns the proxy actor name list serialized by protobuf.""" + if self.proxy_state_manager is None: + return None + + actor_name_list = ActorNameList( + names=self.proxy_state_manager.get_proxy_names().values() + ) + return actor_name_list.SerializeToString() + + def _update_proxy_nodes(self): + """Update the nodes set where proxy actors should run. + + Controller decides where proxy actors should run + (head node and nodes with deployment replicas). + """ + new_proxy_nodes = self.deployment_state_manager.get_active_node_ids() + new_proxy_nodes = new_proxy_nodes - set( + self.cluster_node_info_cache.get_draining_nodes() + ) + new_proxy_nodes.add(self._controller_node_id) + self._proxy_nodes = new_proxy_nodes + + async def run_control_loop(self) -> None: + # NOTE(edoakes): we catch all exceptions here and simply log them, + # because an unhandled exception would cause the main control loop to + # halt, which should *never* happen. + recovering_timeout = RECOVERING_LONG_POLL_BROADCAST_TIMEOUT_S + num_loops = 0 + start_time = time.time() + while True: + loop_start_time = time.time() + try: + await self.run_control_loop_step( + start_time, recovering_timeout, num_loops + ) + except Exception as e: + # we never expect this to happen, but adding this to be safe + logger.exception(f"There was an exception in the control loop: {e}") + await asyncio.sleep(1) + + loop_duration = time.time() - loop_start_time + if loop_duration > 10: + logger.warning( + f"The last control loop was slow (took {loop_duration}s). " + "This is likely caused by running a large number of " + "replicas in a single Ray cluster. Consider using " + "multiple Ray clusters.", + extra={"log_to_stderr": False}, + ) + self.control_loop_duration_gauge_s.set(loop_duration) + + num_loops += 1 + self.num_control_loops_gauge.set(num_loops) + + sleep_start_time = time.time() + await asyncio.sleep(CONTROL_LOOP_INTERVAL_S) + self.sleep_duration_gauge_s.set(time.time() - sleep_start_time) + + async def run_control_loop_step( + self, start_time: float, recovering_timeout: float, num_loops: int + ): + try: + self.cluster_node_info_cache.update() + except Exception: + logger.exception("Exception updating cluster node info cache.") + + if self._shutting_down: + try: + self.shutdown() + except Exception: + logger.exception("Exception during shutdown.") + + if ( + not self.done_recovering_event.is_set() + and time.time() - start_time > recovering_timeout + ): + logger.warning( + f"Replicas still recovering after {recovering_timeout}s, " + "setting done recovering event to broadcast long poll updates." + ) + self.done_recovering_event.set() + + # initialize any_recovering to None to indicate that we don't know if + # we've recovered anything yet + any_recovering: Optional[bool] = None + try: + dsm_update_start_time = time.time() + any_recovering = self.deployment_state_manager.update() + + self.dsm_update_duration_gauge_s.set(time.time() - dsm_update_start_time) + if not self.done_recovering_event.is_set() and not any_recovering: + self.done_recovering_event.set() + if num_loops > 0: + # Only log if we actually needed to recover anything. + logger.info( + "Finished recovering deployments after " + f"{(time.time() - start_time):.2f}s.", + extra={"log_to_stderr": False}, + ) + except Exception: + logger.exception("Exception updating deployment state.") + + try: + asm_update_start_time = time.time() + self.application_state_manager.update() + + self.asm_update_duration_gauge_s.set(time.time() - asm_update_start_time) + except Exception: + logger.exception("Exception updating application state.") + + # Update the proxy nodes set before updating the proxy states, + # so they are more consistent. + node_update_start_time = time.time() + self._update_proxy_nodes() + self.node_update_duration_gauge_s.set(time.time() - node_update_start_time) + + # Don't update proxy_state until after the done recovering event is set, + # otherwise we may start a new proxy but not broadcast it any + # info about available deployments & their replicas. + if self.proxy_state_manager and self.done_recovering_event.is_set(): + try: + proxy_update_start_time = time.time() + self.proxy_state_manager.update(proxy_nodes=self._proxy_nodes) + self.proxy_update_duration_gauge_s.set( + time.time() - proxy_update_start_time + ) + except Exception: + logger.exception("Exception updating proxy state.") + + # When the controller is done recovering, drop invalid handle metrics + # that may be stale for autoscaling + if any_recovering is False: + self.autoscaling_state_manager.drop_stale_handle_metrics( + self.deployment_state_manager.get_alive_replica_actor_ids() + | self.proxy_state_manager.get_alive_proxy_actor_ids() + ) + + def _create_control_loop_metrics(self): + self.node_update_duration_gauge_s = metrics.Gauge( + "serve_controller_node_update_duration_s", + description="The control loop time spent on collecting proxy node info.", + ) + self.proxy_update_duration_gauge_s = metrics.Gauge( + "serve_controller_proxy_state_update_duration_s", + description="The control loop time spent on updating proxy state.", + ) + self.dsm_update_duration_gauge_s = metrics.Gauge( + "serve_controller_deployment_state_update_duration_s", + description="The control loop time spent on updating deployment state.", + ) + self.asm_update_duration_gauge_s = metrics.Gauge( + "serve_controller_application_state_update_duration_s", + description="The control loop time spent on updating application state.", + ) + self.sleep_duration_gauge_s = metrics.Gauge( + "serve_controller_sleep_duration_s", + description="The duration of the last control loop's sleep.", + ) + self.control_loop_duration_gauge_s = metrics.Gauge( + "serve_controller_control_loop_duration_s", + description="The duration of the last control loop.", + ) + self.num_control_loops_gauge = metrics.Gauge( + "serve_controller_num_control_loops", + description=( + "The number of control loops performed by the controller. " + "Increases monotonically over the controller's lifetime." + ), + tag_keys=("actor_id",), + ) + self.num_control_loops_gauge.set_default_tags( + {"actor_id": ray.get_runtime_context().get_actor_id()} + ) + + def _recover_state_from_checkpoint(self): + ( + deployment_time, + serve_config, + target_capacity_direction, + ) = self._read_config_checkpoint() + self._target_capacity_direction = target_capacity_direction + if serve_config is not None: + logger.info( + "Recovered config from checkpoint.", extra={"log_to_stderr": False} + ) + self.apply_config(serve_config, deployment_time=deployment_time) + + def _read_config_checkpoint( + self, + ) -> Tuple[float, Optional[ServeDeploySchema], Optional[TargetCapacityDirection]]: + """Reads the current Serve config checkpoint. + + The Serve config checkpoint stores active application configs and + other metadata. + + Returns: + + If the GCS contains a checkpoint, tuple of: + 1. A deployment timestamp. + 2. A Serve config. This Serve config is reconstructed from the + active application states. It may not exactly match the + submitted config (e.g. the top-level http options may be + different). + 3. The target_capacity direction calculated after the Serve + was submitted. + + If the GCS doesn't contain a checkpoint, returns (0, None, None). + """ + + checkpoint = self.kv_store.get(CONFIG_CHECKPOINT_KEY) + if checkpoint is not None: + ( + deployment_time, + target_capacity, + target_capacity_direction, + config_checkpoints_dict, + ) = pickle.loads(checkpoint) + + return ( + deployment_time, + ServeDeploySchema( + applications=list(config_checkpoints_dict.values()), + target_capacity=target_capacity, + ), + target_capacity_direction, + ) + else: + return (0.0, None, None) + + def _all_running_replicas(self) -> Dict[DeploymentID, List[RunningReplicaInfo]]: + """Used for testing. + + Returned dictionary maps deployment names to replica infos. + """ + + return self.deployment_state_manager.get_running_replica_infos() + + def get_actor_details(self) -> ServeActorDetails: + """Returns the actor details for this controller. + + Currently used for test only. + """ + return self._actor_details + + def get_proxy_details(self, node_id: str) -> Optional[ProxyDetails]: + """Returns the proxy details for the proxy on the given node. + + Currently used for test only. Will return None if the proxy doesn't exist on + the given node. + """ + if self.proxy_state_manager is None: + return None + + return self.proxy_state_manager.get_proxy_details().get(node_id) + + def get_deployment_timestamps(self, app_name: str) -> float: + """Returns the deployment timestamp for the given app. + + Currently used for test only. + """ + for ( + _app_name, + app_status_info, + ) in self.application_state_manager.list_app_statuses().items(): + if app_name == _app_name: + return app_status_info.deployment_timestamp + + def get_deployment_details( + self, app_name: str, deployment_name: str + ) -> DeploymentDetails: + """Returns the deployment details for the app and deployment. + + Currently used for test only. + """ + return self.application_state_manager.list_deployment_details(app_name)[ + deployment_name + ] + + def get_http_config(self) -> HTTPOptions: + """Return the HTTP proxy configuration.""" + if self.proxy_state_manager is None: + return HTTPOptions() + return self.proxy_state_manager.get_config() + + def get_grpc_config(self) -> gRPCOptions: + """Return the gRPC proxy configuration.""" + if self.proxy_state_manager is None: + return gRPCOptions() + return self.proxy_state_manager.get_grpc_config() + + def get_root_url(self): + """Return the root url for the serve instance.""" + if self.proxy_state_manager is None: + return None + http_config = self.get_http_config() + if http_config.root_url == "": + if SERVE_ROOT_URL_ENV_KEY in os.environ: + return os.environ[SERVE_ROOT_URL_ENV_KEY] + else: + # HTTP is disabled + if http_config.host is None: + return "" + return ( + f"http://{build_address(http_config.host, http_config.port)}" + f"{http_config.root_path}" + ) + return http_config.root_url + + def config_checkpoint_deleted(self) -> bool: + """Returns whether the config checkpoint has been deleted. + + Get the config checkpoint from the kv store. If it is None, then it has been + deleted. + """ + return self.kv_store.get(CONFIG_CHECKPOINT_KEY) is None + + def shutdown(self): + """Shuts down the serve instance completely. + + This method will only be triggered when `self._shutting_down` is true. It + deletes the kv store for config checkpoints, sets application state to deleting, + delete all deployments, and shuts down all proxies. Once all these + resources are released, it then kills the controller actor. + """ + if not self._shutting_down: + return + + if self._shutdown_start_time is None: + self._shutdown_start_time = time.time() + logger.info("Controller shutdown started.", extra={"log_to_stderr": False}) + + self.kv_store.delete(CONFIG_CHECKPOINT_KEY) + self.kv_store.delete(LOGGING_CONFIG_CHECKPOINT_KEY) + self.application_state_manager.shutdown() + self.deployment_state_manager.shutdown() + self.endpoint_state.shutdown() + if self.proxy_state_manager: + self.proxy_state_manager.shutdown() + + config_checkpoint_deleted = self.config_checkpoint_deleted() + application_is_shutdown = self.application_state_manager.is_ready_for_shutdown() + deployment_is_shutdown = self.deployment_state_manager.is_ready_for_shutdown() + endpoint_is_shutdown = self.endpoint_state.is_ready_for_shutdown() + proxy_state_is_shutdown = ( + self.proxy_state_manager is None + or self.proxy_state_manager.is_ready_for_shutdown() + ) + if ( + config_checkpoint_deleted + and application_is_shutdown + and deployment_is_shutdown + and endpoint_is_shutdown + and proxy_state_is_shutdown + ): + logger.warning( + "All resources have shut down, controller exiting.", + extra={"log_to_stderr": False}, + ) + _controller_actor = ray.get_runtime_context().current_actor + ray.kill(_controller_actor, no_restart=True) + elif time.time() - self._shutdown_start_time > 10: + if not config_checkpoint_deleted: + logger.warning( + f"{CONFIG_CHECKPOINT_KEY} not yet deleted", + extra={"log_to_stderr": False}, + ) + if not application_is_shutdown: + logger.warning( + "application not yet shutdown", + extra={"log_to_stderr": False}, + ) + if not deployment_is_shutdown: + logger.warning( + "deployment not yet shutdown", + extra={"log_to_stderr": False}, + ) + if not endpoint_is_shutdown: + logger.warning( + "endpoint not yet shutdown", + extra={"log_to_stderr": False}, + ) + if not proxy_state_is_shutdown: + logger.warning( + "proxy_state not yet shutdown", + extra={"log_to_stderr": False}, + ) + + def deploy_applications( + self, name_to_deployment_args_list: Dict[str, List[bytes]] + ) -> None: + """ + Takes in a list of dictionaries that contain deployment arguments. + If same app name deployed, old application will be overwritten. + + Args: + name: Application name. + deployment_args_list: List of serialized deployment information, + where each item in the list is bytes representing the serialized + protobuf `DeploymentArgs` object. `DeploymentArgs` contains all the + information for the single deployment. + """ + name_to_deployment_args = {} + for name, deployment_args_list in name_to_deployment_args_list.items(): + deployment_args_deserialized = [] + for deployment_args_bytes in deployment_args_list: + args = DeploymentArgs.FromString(deployment_args_bytes) + deployment_args_deserialized.append( + { + "deployment_name": args.deployment_name, + "deployment_config_proto_bytes": args.deployment_config, + "replica_config_proto_bytes": args.replica_config, + "deployer_job_id": args.deployer_job_id, + "ingress": args.ingress, + "route_prefix": ( + args.route_prefix if args.HasField("route_prefix") else None + ), + } + ) + name_to_deployment_args[name] = deployment_args_deserialized + + self.application_state_manager.deploy_apps(name_to_deployment_args) + + self.application_state_manager.save_checkpoint() + + def deploy_application(self, name: str, deployment_args_list: List[bytes]) -> None: + """ + Deploy a single application + (as deploy_applications(), but it only takes a single name and deployment args). + This primarily exists as a shim to avoid + changing Java code in https://github.com/ray-project/ray/pull/49168, + and could be removed if the Java code was refactored + to use the new bulk deploy_applications API. + """ + self.deploy_applications({name: deployment_args_list}) + + def apply_config( + self, + config: ServeDeploySchema, + deployment_time: float = 0.0, + ) -> None: + """Apply the config described in `ServeDeploySchema`. + + This will upgrade the applications to the goal state specified in the + config. + + If `deployment_time` is not provided, `time.time()` is used. + """ + ServeUsageTag.API_VERSION.record("v2") + if not deployment_time: + deployment_time = time.time() + + new_config_checkpoint = {} + + _, curr_config, _ = self._read_config_checkpoint() + + self._target_capacity_direction = calculate_target_capacity_direction( + curr_config=curr_config, + new_config=config, + curr_target_capacity_direction=self._target_capacity_direction, + ) + log_target_capacity_change( + self._target_capacity, + config.target_capacity, + self._target_capacity_direction, + ) + self._target_capacity = config.target_capacity + + for app_config in config.applications: + # If the application logging config is not set, use the global logging + # config. + if app_config.logging_config is None and config.logging_config: + app_config.logging_config = config.logging_config + + app_config_dict = app_config.dict(exclude_unset=True) + new_config_checkpoint[app_config.name] = app_config_dict + + self.kv_store.put( + CONFIG_CHECKPOINT_KEY, + pickle.dumps( + ( + deployment_time, + self._target_capacity, + self._target_capacity_direction, + new_config_checkpoint, + ) + ), + ) + + # Declaratively apply the new set of applications. + # This will delete any applications no longer in the config that were + # previously deployed via the REST API. + self.application_state_manager.apply_app_configs( + config.applications, + deployment_time=deployment_time, + target_capacity=self._target_capacity, + target_capacity_direction=self._target_capacity_direction, + ) + + self.application_state_manager.save_checkpoint() + + def get_deployment_info(self, name: str, app_name: str = "") -> bytes: + """Get the current information about a deployment. + + Args: + name: the name of the deployment. + + Returns: + DeploymentRoute's protobuf serialized bytes + + Raises: + KeyError: If the deployment doesn't exist. + """ + id = DeploymentID(name=name, app_name=app_name) + deployment_info = self.deployment_state_manager.get_deployment(id) + if deployment_info is None: + app_msg = f" in application '{app_name}'" if app_name else "" + raise KeyError(f"Deployment '{name}' does not exist{app_msg}.") + + route = self.endpoint_state.get_endpoint_route(id) + + deployment_route = DeploymentRoute( + deployment_info=deployment_info.to_proto(), route=route + ) + return deployment_route.SerializeToString() + + def list_deployments_internal( + self, + ) -> Dict[DeploymentID, Tuple[DeploymentInfo, str]]: + """Gets the current information about all deployments. + + Returns: + Dict(deployment_id, (DeploymentInfo, route)) + """ + return { + id: (info, self.endpoint_state.get_endpoint_route(id)) + for id, info in self.deployment_state_manager.get_deployment_infos().items() + } + + def get_deployment_config( + self, deployment_id: DeploymentID + ) -> Optional[DeploymentConfig]: + """Get the deployment config for the given deployment id. + + Args: + deployment_id: The deployment id to get the config for. + + Returns: + A deployment config object if the deployment id exist, + None otherwise. + """ + deployment_info = self.deployment_state_manager.get_deployment_infos().get( + deployment_id + ) + return deployment_info.deployment_config if deployment_info else None + + def list_deployment_ids(self) -> List[DeploymentID]: + """Gets the current list of all deployments' identifiers.""" + return self.deployment_state_manager._deployment_states.keys() + + def update_deployment_replicas( + self, deployment_id: DeploymentID, target_num_replicas: int + ) -> None: + """Update the target number of replicas for a deployment. + + Args: + deployment_id: The deployment to update. + target_num_replicas: The new target number of replicas. + """ + self.deployment_state_manager.set_target_num_replicas( + deployment_id, target_num_replicas + ) + + def get_serve_instance_details(self, source: Optional[APIType] = None) -> Dict: + """Gets details on all applications on the cluster and system-level info. + + The information includes application and deployment statuses, config options, + error messages, etc. + + Args: + source: If provided, returns application + statuses for applications matching this API type. + Defaults to None, which means all applications are returned. + + Returns: + Dict that follows the format of the schema ServeInstanceDetails. + """ + + http_config = self.get_http_config() + grpc_config = self.get_grpc_config() + applications = {} + + app_statuses = self.application_state_manager.list_app_statuses(source=source) + + # If there are no app statuses, there's no point getting the app configs. + # Moreover, there might be no app statuses because the GCS is down, + # in which case getting the app configs would fail anyway, + # since they're stored in the checkpoint in the GCS. + app_configs = self.get_app_configs() if app_statuses else {} + + for ( + app_name, + app_status_info, + ) in app_statuses.items(): + applications[app_name] = ApplicationDetails( + name=app_name, + route_prefix=self.application_state_manager.get_route_prefix(app_name), + docs_path=self.get_docs_path(app_name), + status=app_status_info.status, + message=app_status_info.message, + last_deployed_time_s=app_status_info.deployment_timestamp, + # This can be none if the app was deployed through + # serve.run, the app is in deleting state, + # or a checkpoint hasn't been set yet + deployed_app_config=app_configs.get(app_name), + source=self.application_state_manager.get_app_source(app_name), + deployments=self.application_state_manager.list_deployment_details( + app_name + ), + ) + + # NOTE(zcin): We use exclude_unset here because we explicitly and intentionally + # fill in all info that should be shown to users. + http_options = HTTPOptionsSchema.parse_obj(http_config.dict(exclude_unset=True)) + grpc_options = gRPCOptionsSchema.parse_obj(grpc_config.dict(exclude_unset=True)) + + return ServeInstanceDetails( + target_capacity=self._target_capacity, + controller_info=self._actor_details, + proxy_location=ProxyLocation._from_deployment_mode(http_config.location), + http_options=http_options, + grpc_options=grpc_options, + proxies=( + self.proxy_state_manager.get_proxy_details() + if self.proxy_state_manager + else None + ), + applications=applications, + target_groups=self.get_target_groups(), + )._get_user_facing_json_serializable_dict(exclude_unset=True) + + def get_target_groups( + self, + app_name: Optional[str] = None, + from_proxy_manager: bool = False, + ) -> List[TargetGroup]: + """Target groups contains information about IP + addresses and ports of all proxies in the cluster. + + This information is used to setup the load balancer. + """ + target_groups: List[TargetGroup] = [] + + if self.proxy_state_manager.get_proxy_details(): + # setting prefix route to "/" because in ray serve, proxy + # accepts requests from the client and routes them to the + # correct application. This is true for both HTTP and gRPC proxies. + target_groups.append( + TargetGroup( + protocol=RequestProtocol.HTTP, + route_prefix="/", + targets=self.proxy_state_manager.get_targets(RequestProtocol.HTTP), + ) + ) + if is_grpc_enabled(self.get_grpc_config()): + target_groups.append( + TargetGroup( + protocol=RequestProtocol.GRPC, + route_prefix="/", + targets=self.proxy_state_manager.get_targets( + RequestProtocol.GRPC + ), + ) + ) + return target_groups + + def get_serve_status(self, name: str = SERVE_DEFAULT_APP_NAME) -> bytes: + """Return application status + Args: + name: application name. If application name doesn't exist, app_status + is NOT_STARTED. + """ + + app_status = self.application_state_manager.get_app_status_info(name) + deployment_statuses = self.application_state_manager.get_deployments_statuses( + name + ) + status_info = StatusOverview( + name=name, + app_status=app_status, + deployment_statuses=deployment_statuses, + ) + return status_info.to_proto().SerializeToString() + + def get_serve_statuses(self, names: List[str]) -> List[bytes]: + statuses = [] + for name in names: + statuses.append(self.get_serve_status(name)) + return statuses + + def list_serve_statuses(self) -> List[bytes]: + statuses = [] + for name in self.application_state_manager.list_app_statuses(): + statuses.append(self.get_serve_status(name)) + return statuses + + def get_app_configs(self) -> Dict[str, ServeApplicationSchema]: + checkpoint = self.kv_store.get(CONFIG_CHECKPOINT_KEY) + if checkpoint is None: + return {} + + _, _, _, config_checkpoints_dict = pickle.loads(checkpoint) + return { + app: ServeApplicationSchema.parse_obj(config) + for app, config in config_checkpoints_dict.items() + } + + def get_all_deployment_statuses(self) -> List[bytes]: + """Gets deployment status bytes for all live deployments.""" + statuses = self.deployment_state_manager.get_deployment_statuses() + return [status.to_proto().SerializeToString() for status in statuses] + + def get_deployment_status( + self, name: str, app_name: str = "" + ) -> Union[None, bytes]: + """Get deployment status by deployment name. + + Args: + name: Deployment name. + app_name: Application name. Default is "" because 1.x + deployments go through this API. + """ + + id = DeploymentID(name=name, app_name=app_name) + status = self.deployment_state_manager.get_deployment_statuses([id]) + if not status: + return None + return status[0].to_proto().SerializeToString() + + def get_docs_path(self, name: str): + """Docs path for application. + + Currently, this is the OpenAPI docs path for FastAPI-integrated applications.""" + return self.application_state_manager.get_docs_path(name) + + def get_ingress_deployment_name(self, app_name: str) -> Optional[str]: + """Name of the ingress deployment in an application. + + Returns: + Ingress deployment name (str): if the application exists. + None: if the application does not exist. + """ + return self.application_state_manager.get_ingress_deployment_name(app_name) + + def delete_apps(self, names: Iterable[str]): + """Delete applications based on names + + During deletion, the application status is DELETING + """ + for name in names: + self.application_state_manager.delete_app(name) + + self.application_state_manager.save_checkpoint() + + def record_request_routing_info(self, info: RequestRoutingInfo): + """Record replica routing information for a replica. + + Args: + info: RequestRoutingInfo including deployment name, replica tag, + multiplex model ids, and routing stats. + """ + self.deployment_state_manager.record_request_routing_info(info) + + def _get_replica_ranks_mapping(self, deployment_id: DeploymentID) -> Dict[str, int]: + """Get the current rank mapping for all replicas in a deployment. + Args: + deployment_id: The deployment ID to get ranks for. + Returns: + Dictionary mapping replica_id to rank. + """ + return self.deployment_state_manager._get_replica_ranks_mapping(deployment_id) + + async def graceful_shutdown(self, wait: bool = True): + """Set the shutting down flag on controller to signal shutdown in + run_control_loop(). + + This is used to signal to the controller that it should proceed with shutdown + process, so it can shut down gracefully. It also waits until the shutdown + event is triggered if wait is true. + + Raises: + RayActorError: if wait is True, the caller waits until the controller + is killed, which raises a RayActorError. + """ + self._shutting_down = True + if not wait: + return + + # This event never gets set. The caller waits indefinitely on this event + # until the controller is killed, which raises a RayActorError. + await self._shutdown_event.wait() + + def _get_logging_config(self) -> Tuple: + """Get the logging configuration (for testing purposes).""" + log_file_path = None + for handler in logger.handlers: + if isinstance(handler, logging.handlers.MemoryHandler): + log_file_path = handler.target.baseFilename + return self.global_logging_config, log_file_path + + def _get_target_capacity_direction(self) -> Optional[TargetCapacityDirection]: + """Gets the controller's scale direction (for testing purposes).""" + + return self._target_capacity_direction + + +def calculate_target_capacity_direction( + curr_config: Optional[ServeDeploySchema], + new_config: ServeDeploySchema, + curr_target_capacity_direction: Optional[float], +) -> Optional[TargetCapacityDirection]: + """Compares two Serve configs to calculate the next scaling direction.""" + + curr_target_capacity = None + next_target_capacity_direction = None + + if curr_config is not None and applications_match(curr_config, new_config): + curr_target_capacity = curr_config.target_capacity + next_target_capacity = new_config.target_capacity + + if curr_target_capacity == next_target_capacity: + next_target_capacity_direction = curr_target_capacity_direction + elif curr_target_capacity is None and next_target_capacity is not None: + # target_capacity is scaling down from None to a number. + next_target_capacity_direction = TargetCapacityDirection.DOWN + elif next_target_capacity is None: + next_target_capacity_direction = None + elif curr_target_capacity < next_target_capacity: + next_target_capacity_direction = TargetCapacityDirection.UP + else: + next_target_capacity_direction = TargetCapacityDirection.DOWN + elif new_config.target_capacity is not None: + # A config with different apps has been applied, and it contains a + # target_capacity. Serve must start scaling this config up. + next_target_capacity_direction = TargetCapacityDirection.UP + else: + next_target_capacity_direction = None + + return next_target_capacity_direction + + +def applications_match(config1: ServeDeploySchema, config2: ServeDeploySchema) -> bool: + """Checks whether the applications in config1 and config2 match. + + Two applications match if they have the same name. + """ + + config1_app_names = {app.name for app in config1.applications} + config2_app_names = {app.name for app in config2.applications} + + return config1_app_names == config2_app_names + + +def log_target_capacity_change( + curr_target_capacity: Optional[float], + next_target_capacity: Optional[float], + next_target_capacity_direction: Optional[TargetCapacityDirection], +): + """Logs changes in the target_capacity.""" + + if curr_target_capacity != next_target_capacity: + if isinstance(next_target_capacity_direction, TargetCapacityDirection): + logger.info( + "Target capacity scaling " + f"{next_target_capacity_direction.value.lower()} " + f"from {curr_target_capacity} to {next_target_capacity}." + ) + else: + logger.info("Target capacity entering 100% at steady state.") diff --git a/lib/python3.12/site-packages/ray/serve/_private/controller_avatar.py b/lib/python3.12/site-packages/ray/serve/_private/controller_avatar.py new file mode 100644 index 0000000000000000000000000000000000000000..a3c5764809478a050bcbf900484f985ae942536a --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/_private/controller_avatar.py @@ -0,0 +1,40 @@ +import ray +from ray.serve._private.constants import SERVE_CONTROLLER_NAME, SERVE_NAMESPACE +from ray.serve._private.default_impl import get_controller_impl +from ray.serve.config import HTTPOptions +from ray.serve.schema import LoggingConfig + + +@ray.remote(num_cpus=0) +class ServeControllerAvatar: + """A hack that proxy the creation of async actors from Java. + + To be removed after https://github.com/ray-project/ray/pull/26037 + + Java api can not support python async actor. If we use java api create + python async actor. The async init method won't be executed. The async + method will fail with pickle error. And the run_control_loop of controller + actor can't be executed too. We use this proxy actor create python async + actor to avoid the above problem. + """ + + def __init__( + self, + http_proxy_port: int = 8000, + ): + try: + self._controller = ray.get_actor( + SERVE_CONTROLLER_NAME, namespace=SERVE_NAMESPACE + ) + except ValueError: + self._controller = None + if self._controller is None: + controller_impl = get_controller_impl() + self._controller = controller_impl.remote( + http_options=HTTPOptions(port=http_proxy_port), + global_logging_config=LoggingConfig(), + ) + + def check_alive(self) -> None: + """No-op to check if this actor is alive.""" + return diff --git a/lib/python3.12/site-packages/ray/serve/_private/default_impl.py b/lib/python3.12/site-packages/ray/serve/_private/default_impl.py new file mode 100644 index 0000000000000000000000000000000000000000..047960abcdf6939f64d596bc66dd0fd52169da69 --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/_private/default_impl.py @@ -0,0 +1,237 @@ +import asyncio +from typing import Callable, Optional, Tuple + +import ray +from ray._common.constants import HEAD_NODE_RESOURCE_NAME +from ray._raylet import GcsClient +from ray.serve._private.cluster_node_info_cache import ( + ClusterNodeInfoCache, + DefaultClusterNodeInfoCache, +) +from ray.serve._private.common import ( + CreatePlacementGroupRequest, + DeploymentHandleSource, + DeploymentID, + EndpointInfo, + RequestMetadata, + RequestProtocol, +) +from ray.serve._private.constants import ( + CONTROLLER_MAX_CONCURRENCY, + RAY_SERVE_ENABLE_TASK_EVENTS, + RAY_SERVE_PROXY_PREFER_LOCAL_NODE_ROUTING, + RAY_SERVE_RUN_ROUTER_IN_SEPARATE_LOOP, + SERVE_CONTROLLER_NAME, + SERVE_NAMESPACE, +) +from ray.serve._private.deployment_scheduler import ( + DefaultDeploymentScheduler, + DeploymentScheduler, +) +from ray.serve._private.grpc_util import gRPCGenericServer +from ray.serve._private.handle_options import DynamicHandleOptions, InitHandleOptions +from ray.serve._private.router import CurrentLoopRouter, Router, SingletonThreadRouter +from ray.serve._private.utils import ( + generate_request_id, + get_current_actor_id, + get_head_node_id, + inside_ray_client_context, + resolve_deployment_response, +) +from ray.util.placement_group import PlacementGroup + +# NOTE: Please read carefully before changing! +# +# These methods are common extension points, therefore these should be +# changed as a Developer API, ie methods should not be renamed, have their +# API modified w/o substantial enough justification + + +def create_cluster_node_info_cache(gcs_client: GcsClient) -> ClusterNodeInfoCache: + return DefaultClusterNodeInfoCache(gcs_client) + + +CreatePlacementGroupFn = Callable[[CreatePlacementGroupRequest], PlacementGroup] + + +def _default_create_placement_group( + request: CreatePlacementGroupRequest, +) -> PlacementGroup: + return ray.util.placement_group( + request.bundles, + request.strategy, + _soft_target_node_id=request.target_node_id, + name=request.name, + lifetime="detached", + ) + + +def create_deployment_scheduler( + cluster_node_info_cache: ClusterNodeInfoCache, + head_node_id_override: Optional[str] = None, + create_placement_group_fn_override: Optional[CreatePlacementGroupFn] = None, +) -> DeploymentScheduler: + head_node_id = head_node_id_override or get_head_node_id() + return DefaultDeploymentScheduler( + cluster_node_info_cache, + head_node_id, + create_placement_group_fn=create_placement_group_fn_override + or _default_create_placement_group, + ) + + +def create_replica_impl(**kwargs): + from ray.serve._private.replica import Replica + + return Replica(**kwargs) + + +def create_replica_metrics_manager(**kwargs): + from ray.serve._private.replica import ReplicaMetricsManager + + return ReplicaMetricsManager(**kwargs) + + +def create_dynamic_handle_options(**kwargs): + return DynamicHandleOptions(**kwargs) + + +def create_init_handle_options(**kwargs): + return InitHandleOptions.create(**kwargs) + + +def get_request_metadata(init_options, handle_options): + _request_context = ray.serve.context._get_serve_request_context() + + request_protocol = RequestProtocol.UNDEFINED + if init_options and init_options._source == DeploymentHandleSource.PROXY: + if _request_context.is_http_request: + request_protocol = RequestProtocol.HTTP + elif _request_context.grpc_context: + request_protocol = RequestProtocol.GRPC + + return RequestMetadata( + request_id=_request_context.request_id + if _request_context.request_id + else generate_request_id(), + internal_request_id=_request_context._internal_request_id + if _request_context._internal_request_id + else generate_request_id(), + call_method=handle_options.method_name, + route=_request_context.route, + app_name=_request_context.app_name, + multiplexed_model_id=handle_options.multiplexed_model_id, + is_streaming=handle_options.stream, + _request_protocol=request_protocol, + grpc_context=_request_context.grpc_context, + _by_reference=True, + ) + + +def _get_node_id_and_az() -> Tuple[str, Optional[str]]: + node_id = ray.get_runtime_context().get_node_id() + try: + cluster_node_info_cache = create_cluster_node_info_cache( + GcsClient(address=ray.get_runtime_context().gcs_address) + ) + cluster_node_info_cache.update() + az = cluster_node_info_cache.get_node_az(node_id) + except Exception: + az = None + + return node_id, az + + +# Interface definition for create_router. +CreateRouterCallable = Callable[[str, DeploymentID, InitHandleOptions], Router] + + +def create_router( + handle_id: str, + deployment_id: DeploymentID, + handle_options: InitHandleOptions, + request_router_class: Optional[Callable] = None, +) -> Router: + # NOTE(edoakes): this is lazy due to a nasty circular import that should be fixed. + from ray.serve.context import _get_global_client + + actor_id = get_current_actor_id() + node_id, availability_zone = _get_node_id_and_az() + controller_handle = _get_global_client()._controller + is_inside_ray_client_context = inside_ray_client_context() + + if handle_options._run_router_in_separate_loop: + router_wrapper_cls = SingletonThreadRouter + else: + try: + asyncio.get_running_loop() + except RuntimeError: + raise RuntimeError( + "No event loop running. You cannot use a handle initialized with " + "`_run_router_in_separate_loop=False` when not inside an asyncio event " + "loop." + ) + + router_wrapper_cls = CurrentLoopRouter + + return router_wrapper_cls( + controller_handle=controller_handle, + deployment_id=deployment_id, + handle_id=handle_id, + self_actor_id=actor_id, + handle_source=handle_options._source, + request_router_class=request_router_class, + # Streaming ObjectRefGenerators are not supported in Ray Client + enable_strict_max_ongoing_requests=not is_inside_ray_client_context, + resolve_request_arg_func=resolve_deployment_response, + node_id=node_id, + availability_zone=availability_zone, + prefer_local_node_routing=handle_options._prefer_local_routing, + ) + + +def add_grpc_address(grpc_server: gRPCGenericServer, server_address: str): + """Helper function to add an address to a gRPC server.""" + grpc_server.add_insecure_port(server_address) + + +def get_proxy_handle(endpoint: DeploymentID, info: EndpointInfo): + # NOTE(zcin): needs to be lazy import due to a circular dependency. + # We should not be importing from application_state in context. + from ray.serve.context import _get_global_client + + client = _get_global_client() + handle = client.get_handle(endpoint.name, endpoint.app_name, check_exists=True) + + # NOTE(zcin): It's possible that a handle is already initialized + # if a deployment with the same name and application name was + # deleted, then redeployed later. However this is not an issue since + # we initialize all handles with the same init options. + if not handle.is_initialized: + # NOTE(zcin): since the router is eagerly initialized here, the + # proxy will receive the replica set from the controller early. + handle._init( + _prefer_local_routing=RAY_SERVE_PROXY_PREFER_LOCAL_NODE_ROUTING, + _source=DeploymentHandleSource.PROXY, + _run_router_in_separate_loop=RAY_SERVE_RUN_ROUTER_IN_SEPARATE_LOOP, + ) + + return handle.options(stream=not info.app_is_cross_language) + + +def get_controller_impl(): + from ray.serve._private.controller import ServeController + + controller_impl = ray.remote( + name=SERVE_CONTROLLER_NAME, + namespace=SERVE_NAMESPACE, + num_cpus=0, + lifetime="detached", + max_restarts=-1, + max_task_retries=-1, + resources={HEAD_NODE_RESOURCE_NAME: 0.001}, + max_concurrency=CONTROLLER_MAX_CONCURRENCY, + enable_task_events=RAY_SERVE_ENABLE_TASK_EVENTS, + )(ServeController) + + return controller_impl diff --git a/lib/python3.12/site-packages/ray/serve/_private/deploy_utils.py b/lib/python3.12/site-packages/ray/serve/_private/deploy_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..5f700d01ae8fda01e9f479743cfd412a7fdb6e8a --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/_private/deploy_utils.py @@ -0,0 +1,129 @@ +import hashlib +import json +import logging +import time +from typing import Any, Dict, Optional, Union + +import ray +import ray.util.serialization_addons +from ray.serve._private.common import DeploymentID +from ray.serve._private.config import DeploymentConfig, ReplicaConfig +from ray.serve._private.constants import SERVE_LOGGER_NAME +from ray.serve._private.deployment_info import DeploymentInfo +from ray.serve.schema import ServeApplicationSchema + +logger = logging.getLogger(SERVE_LOGGER_NAME) + + +def get_deploy_args( + name: str, + replica_config: ReplicaConfig, + ingress: bool = False, + deployment_config: Optional[Union[DeploymentConfig, Dict[str, Any]]] = None, + version: Optional[str] = None, + route_prefix: Optional[str] = None, + serialized_autoscaling_policy_def: Optional[bytes] = None, + serialized_request_router_cls: Optional[bytes] = None, +) -> Dict: + """ + Takes a deployment's configuration, and returns the arguments needed + for the controller to deploy it. + """ + if deployment_config is None: + deployment_config = {} + + if isinstance(deployment_config, dict): + deployment_config = DeploymentConfig.parse_obj(deployment_config) + elif not isinstance(deployment_config, DeploymentConfig): + raise TypeError("config must be a DeploymentConfig or a dictionary.") + + deployment_config.version = version + + controller_deploy_args = { + "deployment_name": name, + "deployment_config_proto_bytes": deployment_config.to_proto_bytes(), + "replica_config_proto_bytes": replica_config.to_proto_bytes(), + "route_prefix": route_prefix, + "deployer_job_id": ray.get_runtime_context().get_job_id(), + "ingress": ingress, + "serialized_autoscaling_policy_def": serialized_autoscaling_policy_def, + "serialized_request_router_cls": serialized_request_router_cls, + } + + return controller_deploy_args + + +def deploy_args_to_deployment_info( + deployment_name: str, + deployment_config_proto_bytes: bytes, + replica_config_proto_bytes: bytes, + deployer_job_id: Union[str, bytes], + app_name: Optional[str] = None, + ingress: bool = False, + route_prefix: Optional[str] = None, + **kwargs, +) -> DeploymentInfo: + """Takes deployment args passed to the controller after building an application and + constructs a DeploymentInfo object. + """ + + deployment_config = DeploymentConfig.from_proto_bytes(deployment_config_proto_bytes) + version = deployment_config.version + replica_config = ReplicaConfig.from_proto_bytes( + replica_config_proto_bytes, deployment_config.needs_pickle() + ) + + # Java API passes in JobID as bytes + if isinstance(deployer_job_id, bytes): + deployer_job_id = ray.JobID.from_int( + int.from_bytes(deployer_job_id, "little") + ).hex() + + return DeploymentInfo( + actor_name=DeploymentID( + name=deployment_name, app_name=app_name + ).to_replica_actor_class_name(), + version=version, + deployment_config=deployment_config, + replica_config=replica_config, + deployer_job_id=deployer_job_id, + start_time_ms=int(time.time() * 1000), + route_prefix=route_prefix, + ingress=ingress, + ) + + +def get_app_code_version(app_config: ServeApplicationSchema) -> str: + """Returns the code version of an application. + + Args: + app_config: The application config. + + Returns: a hash of the import path and (application level) runtime env representing + the code version of the application. + """ + request_router_configs = [ + deployment.request_router_config + for deployment in app_config.deployments + if isinstance(deployment.request_router_config, dict) + ] + deployment_autoscaling_policies = [ + deployment_config.autoscaling_config.get("policy", None) + for deployment_config in app_config.deployments + if isinstance(deployment_config.autoscaling_config, dict) + ] + encoded = json.dumps( + { + "import_path": app_config.import_path, + "runtime_env": app_config.runtime_env, + "args": app_config.args, + # NOTE: trigger a change in the code version when + # application level autoscaling policy is changed or + # any one of the deployment level autoscaling policy is changed + "autoscaling_policy": app_config.autoscaling_policy, + "deployment_autoscaling_policies": deployment_autoscaling_policies, + "request_router_configs": request_router_configs, + }, + sort_keys=True, + ).encode("utf-8") + return hashlib.sha1(encoded).hexdigest() diff --git a/lib/python3.12/site-packages/ray/serve/_private/deployment_info.py b/lib/python3.12/site-packages/ray/serve/_private/deployment_info.py new file mode 100644 index 0000000000000000000000000000000000000000..5413c7878aa013e68f93cc0b15acab9880a5cc05 --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/_private/deployment_info.py @@ -0,0 +1,169 @@ +from typing import Any, Dict, Optional + +import ray +from ray.serve._private.common import TargetCapacityDirection +from ray.serve._private.config import DeploymentConfig, ReplicaConfig +from ray.serve.generated.serve_pb2 import ( + DeploymentInfo as DeploymentInfoProto, + TargetCapacityDirection as TargetCapacityDirectionProto, +) + + +class DeploymentInfo: + def __init__( + self, + deployment_config: DeploymentConfig, + replica_config: ReplicaConfig, + start_time_ms: int, + deployer_job_id: str, + actor_name: Optional[str] = None, + version: Optional[str] = None, + end_time_ms: Optional[int] = None, + route_prefix: str = None, + ingress: bool = False, + target_capacity: Optional[float] = None, + target_capacity_direction: Optional[TargetCapacityDirection] = None, + ): + self.deployment_config = deployment_config + self.replica_config = replica_config + # The time when .deploy() was first called for this deployment. + self.start_time_ms = start_time_ms + self.actor_name = actor_name + self.version = version + self.deployer_job_id = deployer_job_id + # The time when this deployment was deleted. + self.end_time_ms = end_time_ms + + # ephermal state + self._cached_actor_def = None + + self.route_prefix = route_prefix + self.ingress = ingress + + self.target_capacity = target_capacity + self.target_capacity_direction = target_capacity_direction + + def __getstate__(self) -> Dict[Any, Any]: + clean_dict = self.__dict__.copy() + del clean_dict["_cached_actor_def"] + return clean_dict + + def __setstate__(self, d: Dict[Any, Any]) -> None: + self.__dict__ = d + self._cached_actor_def = None + + def update( + self, + deployment_config: DeploymentConfig = None, + replica_config: ReplicaConfig = None, + version: str = None, + route_prefix: str = None, + ) -> "DeploymentInfo": + return DeploymentInfo( + deployment_config=deployment_config or self.deployment_config, + replica_config=replica_config or self.replica_config, + start_time_ms=self.start_time_ms, + deployer_job_id=self.deployer_job_id, + actor_name=self.actor_name, + version=version or self.version, + end_time_ms=self.end_time_ms, + route_prefix=route_prefix or self.route_prefix, + ingress=self.ingress, + target_capacity=self.target_capacity, + target_capacity_direction=self.target_capacity_direction, + ) + + def set_target_capacity( + self, + new_target_capacity: Optional[float], + new_target_capacity_direction: Optional[TargetCapacityDirection], + ): + self.target_capacity = new_target_capacity + self.target_capacity_direction = new_target_capacity_direction + + def config_changed(self, other) -> bool: + return ( + self.deployment_config != other.deployment_config + or self.replica_config.ray_actor_options + != other.replica_config.ray_actor_options + or other.version is None + or self.version != other.version + ) + + @property + def actor_def(self): + if self._cached_actor_def is None: + assert self.actor_name is not None + + # Break circular import :(. + from ray.serve._private.replica import ReplicaActor + + # Dynamically create a new class with custom name here so Ray picks it up + # correctly in actor metadata table and observability stack. + self._cached_actor_def = ray.remote( + type( + self.actor_name, + (ReplicaActor,), + dict(ReplicaActor.__dict__), + ) + ) + + return self._cached_actor_def + + @classmethod + def from_proto(cls, proto: DeploymentInfoProto): + deployment_config = ( + DeploymentConfig.from_proto(proto.deployment_config) + if proto.deployment_config + else None + ) + + target_capacity = proto.target_capacity if proto.target_capacity != -1 else None + + target_capacity_direction = TargetCapacityDirectionProto.Name( + proto.target_capacity_direction + ) + if target_capacity_direction == "UNSET": + target_capacity_direction = None + else: + target_capacity_direction = TargetCapacityDirection( + target_capacity_direction + ) + + data = { + "deployment_config": deployment_config, + "replica_config": ReplicaConfig.from_proto( + proto.replica_config, + deployment_config.needs_pickle() if deployment_config else True, + ), + "start_time_ms": proto.start_time_ms, + "actor_name": proto.actor_name if proto.actor_name != "" else None, + "version": proto.version if proto.version != "" else None, + "end_time_ms": proto.end_time_ms if proto.end_time_ms != 0 else None, + "deployer_job_id": ray.get_runtime_context().get_job_id(), + "target_capacity": target_capacity, + "target_capacity_direction": target_capacity_direction, + } + + return cls(**data) + + def to_proto(self): + data = { + "start_time_ms": self.start_time_ms, + "actor_name": self.actor_name, + "version": self.version, + "end_time_ms": self.end_time_ms, + } + if self.deployment_config: + data["deployment_config"] = self.deployment_config.to_proto() + if self.replica_config: + data["replica_config"] = self.replica_config.to_proto() + if self.target_capacity is None: + data["target_capacity"] = -1 + else: + data["target_capacity"] = self.target_capacity + if self.target_capacity_direction is None: + data["target_capacity_direction"] = TargetCapacityDirectionProto.UNSET + else: + data["target_capacity_direction"] = self.target_capacity_direction.name + return DeploymentInfoProto(**data) diff --git a/lib/python3.12/site-packages/ray/serve/_private/deployment_node.py b/lib/python3.12/site-packages/ray/serve/_private/deployment_node.py new file mode 100644 index 0000000000000000000000000000000000000000..375c49e830c4788d973c555e0ede3d5f1c883730 --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/_private/deployment_node.py @@ -0,0 +1,56 @@ +from typing import Any, Dict, List, Optional, Tuple + +from ray.dag import DAGNode +from ray.dag.format_utils import get_dag_node_str +from ray.serve.deployment import Deployment +from ray.serve.handle import DeploymentHandle + + +class DeploymentNode(DAGNode): + """Represents a deployment node in a DAG authored Ray DAG API.""" + + def __init__( + self, + # For serve structured deployment, deployment body can be import path + # to the class or function instead. + deployment: Deployment, + app_name: str, + deployment_init_args: Tuple[Any], + deployment_init_kwargs: Dict[str, Any], + ray_actor_options: Dict[str, Any], + other_args_to_resolve: Optional[Dict[str, Any]] = None, + ): + # Assign instance variables in base class constructor. + super().__init__( + deployment_init_args, + deployment_init_kwargs, + ray_actor_options, + other_args_to_resolve=other_args_to_resolve, + ) + self._app_name = app_name + self._deployment = deployment + self._deployment_handle = DeploymentHandle( + self._deployment.name, self._app_name + ) + + def _copy_impl( + self, + new_args: List[Any], + new_kwargs: Dict[str, Any], + new_options: Dict[str, Any], + new_other_args_to_resolve: Dict[str, Any], + ): + return DeploymentNode( + self._deployment, + self._app_name, + new_args, + new_kwargs, + new_options, + other_args_to_resolve=new_other_args_to_resolve, + ) + + def __str__(self) -> str: + return get_dag_node_str(self, str(self._deployment)) + + def get_deployment_name(self): + return self._deployment.name diff --git a/lib/python3.12/site-packages/ray/serve/_private/deployment_scheduler.py b/lib/python3.12/site-packages/ray/serve/_private/deployment_scheduler.py new file mode 100644 index 0000000000000000000000000000000000000000..424a6dfea5292473a6ad5617b023053171af69ef --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/_private/deployment_scheduler.py @@ -0,0 +1,813 @@ +import copy +import logging +import sys +from abc import ABC, abstractmethod +from collections import defaultdict +from dataclasses import dataclass +from enum import Enum +from functools import total_ordering +from typing import Any, Callable, Dict, List, Optional, Set, Tuple + +import ray +from ray.serve._private.cluster_node_info_cache import ClusterNodeInfoCache +from ray.serve._private.common import ( + CreatePlacementGroupRequest, + DeploymentID, + ReplicaID, +) +from ray.serve._private.config import ReplicaConfig +from ray.serve._private.constants import ( + RAY_SERVE_HIGH_PRIORITY_CUSTOM_RESOURCES, + RAY_SERVE_USE_COMPACT_SCHEDULING_STRATEGY, + SERVE_LOGGER_NAME, +) +from ray.util.scheduling_strategies import ( + LabelMatchExpressionsT, + NodeAffinitySchedulingStrategy, + NodeLabelSchedulingStrategy, + PlacementGroupSchedulingStrategy, +) + +logger = logging.getLogger(SERVE_LOGGER_NAME) + + +class SpreadDeploymentSchedulingPolicy: + """A scheduling policy that spreads replicas with best effort.""" + + pass + + +@total_ordering +class Resources(dict): + # Custom resource priority from environment variable + CUSTOM_PRIORITY: List[str] = RAY_SERVE_HIGH_PRIORITY_CUSTOM_RESOURCES + EPSILON = 1e-9 + + def get(self, key: str): + val = super().get(key) + if val is not None: + return val + + # Implicit resources by default have 1 total + if key.startswith(ray._raylet.IMPLICIT_RESOURCE_PREFIX): + return 1 + + # Otherwise by default there is 0 of this resource + return 0 + + def can_fit(self, other): + keys = set(self.keys()) | set(other.keys()) + # We add a small epsilon to avoid floating point precision issues. + return all(self.get(k) + self.EPSILON >= other.get(k) for k in keys) + + def __eq__(self, other): + keys = set(self.keys()) | set(other.keys()) + return all([self.get(k) == other.get(k) for k in keys]) + + def __add__(self, other): + keys = set(self.keys()) | set(other.keys()) + + kwargs = dict() + for key in keys: + if key.startswith(ray._raylet.IMPLICIT_RESOURCE_PREFIX): + kwargs[key] = min(1.0, self.get(key) + other.get(key)) + else: + kwargs[key] = self.get(key) + other.get(key) + + return Resources(kwargs) + + def __sub__(self, other): + keys = set(self.keys()) | set(other.keys()) + kwargs = {key: self.get(key) - other.get(key) for key in keys} + return Resources(kwargs) + + def __lt__(self, other): + """Determines priority when sorting a list of SoftResources. + 1. Custom resources defined in RAY_SERVE_HIGH_PRIORITY_CUSTOM_RESOURCES (sorted by priority) + 2. GPU + 3. CPU + 4. memory + 5. Other custom resources + This means a resource with a larger number of high-priority resources is always + sorted higher than one with fewer, regardless of other types. + """ + + keys = set(self.keys()) | set(other.keys()) + custom_keys = keys - {"GPU", "CPU", "memory"} + + for key in self.CUSTOM_PRIORITY: + if self.get(key) < other.get(key): + return True + elif self.get(key) > other.get(key): + return False + + if self.get("GPU") < other.get("GPU"): + return True + elif self.get("GPU") > other.get("GPU"): + return False + + if self.get("CPU") < other.get("CPU"): + return True + elif self.get("CPU") > other.get("CPU"): + return False + + if self.get("memory") < other.get("memory"): + return True + elif self.get("memory") > other.get("memory"): + return False + + for key in custom_keys - set(self.CUSTOM_PRIORITY): + if self.get(key) < other.get(key): + return True + elif self.get(key) > other.get(key): + return False + + return False + + +class ReplicaSchedulingRequestStatus(str, Enum): + """The status of a replica scheduling request.""" + + IN_PROGRESS = "IN_PROGRESS" + SUCCEEDED = "SUCCEEDED" + ACTOR_CREATION_FAILED = "ACTOR_CREATION_FAILED" + PLACEMENT_GROUP_CREATION_FAILED = "PLACEMENT_GROUP_CREATION_FAILED" + + +@dataclass +class ReplicaSchedulingRequest: + """Request to schedule a single replica. + + The scheduler is responsible for scheduling + based on the deployment scheduling policy. + """ + + replica_id: ReplicaID + actor_def: ray.actor.ActorClass + actor_resources: Dict + actor_options: Dict + actor_init_args: Tuple + on_scheduled: Callable + status: ReplicaSchedulingRequestStatus = ReplicaSchedulingRequestStatus.IN_PROGRESS + # Placement group bundles and strategy *for this replica*. + # These are optional: by default replicas do not have a placement group. + placement_group_bundles: Optional[List[Dict[str, float]]] = None + placement_group_strategy: Optional[str] = None + max_replicas_per_node: Optional[int] = None + + @property + def required_resources(self) -> Resources: + """The resources required to schedule this replica on a node. + + If this replica uses a strict pack placement group, the + required resources is the sum of the placement group bundles. + Otherwise, required resources is simply the actor resources. + """ + + if ( + self.placement_group_bundles is not None + and self.placement_group_strategy == "STRICT_PACK" + ): + return sum( + [Resources(bundle) for bundle in self.placement_group_bundles], + Resources(), + ) + else: + required = Resources(self.actor_resources) + + # Using implicit resource (resources that every node + # implicitly has and total is 1) + # to limit the number of replicas on a single node. + if self.max_replicas_per_node is not None: + deployment_id = self.replica_id.deployment_id + implicit_resource = ( + f"{ray._raylet.IMPLICIT_RESOURCE_PREFIX}" + f"{deployment_id.app_name}:{deployment_id.name}" + ) + required[implicit_resource] = 1.0 / self.max_replicas_per_node + + return required + + +@dataclass +class DeploymentDownscaleRequest: + """Request to stop a certain number of replicas. + + The scheduler is responsible for + choosing the replicas to stop. + """ + + deployment_id: DeploymentID + num_to_stop: int + + +@dataclass +class DeploymentSchedulingInfo: + deployment_id: DeploymentID + scheduling_policy: Any + actor_resources: Optional[Resources] = None + placement_group_bundles: Optional[List[Resources]] = None + placement_group_strategy: Optional[str] = None + max_replicas_per_node: Optional[int] = None + + @property + def required_resources(self) -> Resources: + """The resources required to schedule a replica of this deployment on a node. + + If this replicas uses a strict pack placement group, the + required resources is the sum of the placement group bundles. + Otherwise, required resources is simply the actor resources. + """ + + if ( + self.placement_group_bundles is not None + and self.placement_group_strategy == "STRICT_PACK" + ): + return sum(self.placement_group_bundles, Resources()) + else: + required = self.actor_resources + + # Using implicit resource (resources that every node + # implicitly has and total is 1) + # to limit the number of replicas on a single node. + if self.max_replicas_per_node: + implicit_resource = ( + f"{ray._raylet.IMPLICIT_RESOURCE_PREFIX}" + f"{self.deployment_id.app_name}:{self.deployment_id.name}" + ) + required[implicit_resource] = 1.0 / self.max_replicas_per_node + + return required + + def is_non_strict_pack_pg(self) -> bool: + return ( + self.placement_group_bundles is not None + and self.placement_group_strategy != "STRICT_PACK" + ) + + +@dataclass +class LaunchingReplicaInfo: + """Describes a replica for which a schedule request has been sent to + core but has not been scheduled (placed on a node) yet. + + Args: + target_node_id: The exact node that's been requested for this + replica. This is best effort and may not be fulfilled. + target_labels: The node labels that have been requested for this + replica. This is best effort and may not be fulfilled. + """ + + target_node_id: Optional[str] = None + target_labels: Optional[Dict[str, Any]] = None + + +def _flatten( + deployment_to_replicas: Dict[DeploymentID, Dict[ReplicaID, Any]] +) -> Dict[ReplicaID, Any]: + """Flattens a dict of {deployment_id: {replica_id: val}} to {replica_id: val}.""" + + return { + replica_id: val + for replicas in deployment_to_replicas.values() + for replica_id, val in replicas.items() + } + + +class DeploymentScheduler(ABC): + """A centralized scheduler for all Serve deployments. + + It makes a batch of scheduling decisions in each update cycle. + """ + + def __init__( + self, + cluster_node_info_cache: ClusterNodeInfoCache, + head_node_id: str, + create_placement_group_fn: Callable, + ): + # {deployment_id: scheduling_policy} + self._deployments: Dict[DeploymentID, DeploymentSchedulingInfo] = {} + # Replicas that are waiting to be scheduled. + # {deployment_id: {replica_id: deployment_upscale_request}} + self._pending_replicas: Dict[ + DeploymentID, Dict[str, ReplicaSchedulingRequest] + ] = defaultdict(dict) + # Replicas that are being scheduled. + # The underlying actors have been submitted. + # {deployment_id: {replica_id: target_node_id}} + self._launching_replicas: Dict[ + DeploymentID, Dict[str, LaunchingReplicaInfo] + ] = defaultdict(dict) + # Replicas that are recovering. + # We don't know where those replicas are running. + # {deployment_id: {replica_id}} + self._recovering_replicas = defaultdict(set) + # Replicas that are running. + # We know where those replicas are running. + # {deployment_id: {replica_id: running_node_id}} + self._running_replicas = defaultdict(dict) + + self._cluster_node_info_cache = cluster_node_info_cache + self._head_node_id = head_node_id + self._create_placement_group_fn = create_placement_group_fn + + def on_deployment_created( + self, + deployment_id: DeploymentID, + scheduling_policy: SpreadDeploymentSchedulingPolicy, + ) -> None: + """Called whenever a new deployment is created.""" + assert deployment_id not in self._pending_replicas + assert deployment_id not in self._launching_replicas + assert deployment_id not in self._recovering_replicas + assert deployment_id not in self._running_replicas + self._deployments[deployment_id] = DeploymentSchedulingInfo( + deployment_id=deployment_id, scheduling_policy=scheduling_policy + ) + + def on_deployment_deployed( + self, + deployment_id: DeploymentID, + replica_config: ReplicaConfig, + ) -> None: + assert deployment_id in self._deployments + + info = self._deployments[deployment_id] + info.actor_resources = Resources(replica_config.resource_dict) + info.max_replicas_per_node = replica_config.max_replicas_per_node + if replica_config.placement_group_bundles: + info.placement_group_bundles = [ + Resources(bundle) for bundle in replica_config.placement_group_bundles + ] + if replica_config.placement_group_strategy: + info.placement_group_strategy = replica_config.placement_group_strategy + + def on_deployment_deleted(self, deployment_id: DeploymentID) -> None: + """Called whenever a deployment is deleted.""" + assert not self._pending_replicas[deployment_id] + self._pending_replicas.pop(deployment_id, None) + + assert not self._launching_replicas[deployment_id] + self._launching_replicas.pop(deployment_id, None) + + assert not self._recovering_replicas[deployment_id] + self._recovering_replicas.pop(deployment_id, None) + + assert not self._running_replicas[deployment_id] + self._running_replicas.pop(deployment_id, None) + + del self._deployments[deployment_id] + + def on_replica_stopping(self, replica_id: ReplicaID) -> None: + """Called whenever a deployment replica is being stopped.""" + deployment_id = replica_id.deployment_id + self._pending_replicas[deployment_id].pop(replica_id, None) + self._launching_replicas[deployment_id].pop(replica_id, None) + self._recovering_replicas[deployment_id].discard(replica_id) + self._running_replicas[deployment_id].pop(replica_id, None) + + def on_replica_running(self, replica_id: ReplicaID, node_id: str) -> None: + """Called whenever a deployment replica is running with a known node id.""" + deployment_id = replica_id.deployment_id + assert replica_id not in self._pending_replicas[deployment_id] + + self._launching_replicas[deployment_id].pop(replica_id, None) + self._recovering_replicas[deployment_id].discard(replica_id) + + self._running_replicas[deployment_id][replica_id] = node_id + + def on_replica_recovering(self, replica_id: ReplicaID) -> None: + """Called whenever a deployment replica is recovering.""" + deployment_id = replica_id.deployment_id + assert replica_id not in self._pending_replicas[deployment_id] + assert replica_id not in self._launching_replicas[deployment_id] + assert replica_id not in self._running_replicas[deployment_id] + assert replica_id not in self._recovering_replicas[deployment_id] + + self._recovering_replicas[deployment_id].add(replica_id) + + def _on_replica_launching( + self, + replica_id: ReplicaID, + target_node_id: Optional[str] = None, + target_labels: Optional[Dict[str, Any]] = None, + ): + deployment_id = replica_id.deployment_id + self._launching_replicas[deployment_id][replica_id] = LaunchingReplicaInfo( + target_node_id=target_node_id, target_labels=target_labels + ) + + def _get_node_to_running_replicas( + self, deployment_id: Optional[DeploymentID] = None + ) -> Dict[str, Set[ReplicaID]]: + res = defaultdict(set) + if deployment_id: + for replica_id, node_id in self._running_replicas[deployment_id].items(): + res[node_id].add(replica_id) + else: + for _, replicas in self._running_replicas.items(): + for replica_id, node_id in replicas.items(): + res[node_id].add(replica_id) + + return res + + def _get_available_resources_per_node(self) -> Dict[str, Resources]: + """Gets current available resources per node. + + This returns a conservative view of the available resources + currently in the cluster. It returns the minimum of: + + 1. The available resources per node fetched and cached from the + GCS every control loop. + 2. The remaining resources left over on each node after + subtracting the resources taken up by running (already + scheduled by core) and launching (to-be-scheduled and soft + targeting that node) replicas. + + Note that (1) may not be accurate because it uses cached info + and there is a delay from fetching data from GCS, and (2) may + not be accurate because there can be other actors (not replicas) + running in the cluster, and launching replicas may not end up on + the node we're targeting. So the information returned from this + method is only best effort. + """ + + available_resources = ( + self._cluster_node_info_cache.get_available_resources_per_node() + ) + total_resources = self._cluster_node_info_cache.get_total_resources_per_node() + + gcs_info = {node_id: Resources(r) for node_id, r in available_resources.items()} + + # Manually calculate available resources per node by subtracting + # launching and running replicas from total resources + total_minus_replicas = { + node_id: Resources(resources) + for node_id, resources in total_resources.items() + } + for deployment_id, replicas in self._launching_replicas.items(): + deployment = self._deployments[deployment_id] + for info in replicas.values(): + target_node_id = info.target_node_id + if not target_node_id or target_node_id not in total_minus_replicas: + continue + + total_minus_replicas[target_node_id] -= deployment.required_resources + + for deployment_id, replicas in self._running_replicas.items(): + deployment = self._deployments[deployment_id] + for node_id in replicas.values(): + if node_id not in total_minus_replicas: + continue + + total_minus_replicas[node_id] -= deployment.required_resources + + def custom_min(a: Resources, b: Resources): + keys = set(a.keys()) | set(b.keys()) + res = Resources() + for key in keys: + res[key] = min(a.get(key), b.get(key)) + return res + + # Filter by active node ids (alive but not draining) + return { + node_id: custom_min( + gcs_info.get(node_id, Resources()), + total_minus_replicas.get(node_id, Resources()), + ) + for node_id in self._cluster_node_info_cache.get_active_node_ids() + } + + def _best_fit_node( + self, required_resources: Resources, available_resources: Dict[str, Resources] + ) -> Optional[str]: + """Chooses a node using best fit strategy. + + This strategy picks the node where, if the required resources + were to be scheduled on that node, it will leave the smallest + remaining space. This minimizes fragmentation of resources. + """ + + min_remaining_space = None + chosen_node = None + + for node_id in available_resources: + if not available_resources[node_id].can_fit(required_resources): + continue + + # TODO(zcin): We can make this better by only considering + # custom resources that required_resources has. + remaining_space = available_resources[node_id] - required_resources + if min_remaining_space is None or remaining_space < min_remaining_space: + min_remaining_space = remaining_space + chosen_node = node_id + + return chosen_node + + @abstractmethod + def schedule( + self, + upscales: Dict[DeploymentID, List[ReplicaSchedulingRequest]], + downscales: Dict[DeploymentID, DeploymentDownscaleRequest], + ) -> Dict[DeploymentID, Set[ReplicaID]]: + """Called for each update cycle to do batch scheduling. + + Args: + upscales: a dict of deployment name to a list of replicas to schedule. + downscales: a dict of deployment name to a downscale request. + + Returns: + The name of replicas to stop for each deployment. + """ + raise NotImplementedError + + def _schedule_replica( + self, + scheduling_request: ReplicaSchedulingRequest, + default_scheduling_strategy: str, + target_node_id: Optional[str] = None, + target_labels: Optional[LabelMatchExpressionsT] = None, + ): + """Schedule a replica from a scheduling request. + + The following special scheduling strategies will be used, in + order of highest to lowest priority. + 1. If a replica requires placement groups, we will choose to use + a `PlacementGroupSchedulingStrategy`. This can also take a + target node into consideration (soft target), if provided. + However it cannot take into account target labels. + 2. If a `target_node_id` is provided, we will choose to use a + `NodeAffinitySchedulingStrategy`. + 3. If `target_labels` is provided, we will choose to use a + `NodeLabelSchedulingStrategy`. + + Args: + scheduling_request: A request to schedule a replica. + default_scheduling_strategy: The scheduling strategy to fall + back to if no special scheduling strategy is necessary. + target_node_id: Attempt to schedule this replica onto this + target node. + target_labels: Attempt to schedule this replica onto nodes + with these target labels. + """ + + replica_id = scheduling_request.replica_id + deployment_id = replica_id.deployment_id + placement_group = None + + scheduling_strategy = default_scheduling_strategy + if scheduling_request.placement_group_bundles is not None: + placement_group_strategy = ( + scheduling_request.placement_group_strategy + if scheduling_request.placement_group_strategy + else "PACK" + ) + try: + pg = self._create_placement_group_fn( + CreatePlacementGroupRequest( + bundles=scheduling_request.placement_group_bundles, + strategy=placement_group_strategy, + target_node_id=target_node_id, + name=scheduling_request.actor_options["name"], + ) + ) + except Exception: + # We add a defensive exception here, so the controller can + # make progress even if the placement group isn't created. + # See https://github.com/ray-project/ray/issues/43888. + logger.exception( + f"Failed to create a placement group for {replica_id}." + ) + scheduling_request.status = ( + ReplicaSchedulingRequestStatus.PLACEMENT_GROUP_CREATION_FAILED + ) + return + scheduling_strategy = PlacementGroupSchedulingStrategy( + placement_group=pg, + placement_group_capture_child_tasks=True, + ) + target_labels = None + elif target_node_id is not None: + scheduling_strategy = NodeAffinitySchedulingStrategy( + node_id=target_node_id, soft=True, _spill_on_unavailable=True + ) + target_labels = None + elif target_labels is not None: + scheduling_strategy = NodeLabelSchedulingStrategy( + hard={}, soft=target_labels + ) + target_node_id = None + + actor_options = copy.deepcopy(scheduling_request.actor_options) + if scheduling_request.max_replicas_per_node is not None: + if "resources" not in actor_options: + actor_options["resources"] = {} + # Using implicit resource (resources that every node + # implicitly has and total is 1) + # to limit the number of replicas on a single node. + actor_options["resources"][ + f"{ray._raylet.IMPLICIT_RESOURCE_PREFIX}" + f"{deployment_id.app_name}:{deployment_id.name}" + ] = (1.0 / scheduling_request.max_replicas_per_node) + + try: + actor_handle = scheduling_request.actor_def.options( + scheduling_strategy=scheduling_strategy, + **actor_options, + ).remote(*scheduling_request.actor_init_args) + except Exception: + # We add a defensive exception here, so the controller can + # make progress even if the actor options are misconfigured. + logger.exception(f"Failed to create an actor for {replica_id}.") + scheduling_request.status = ( + ReplicaSchedulingRequestStatus.ACTOR_CREATION_FAILED + ) + return + + del self._pending_replicas[deployment_id][replica_id] + self._on_replica_launching( + replica_id, target_node_id=target_node_id, target_labels=target_labels + ) + + if isinstance(scheduling_strategy, PlacementGroupSchedulingStrategy): + placement_group = scheduling_strategy.placement_group + + scheduling_request.status = ReplicaSchedulingRequestStatus.SUCCEEDED + scheduling_request.on_scheduled(actor_handle, placement_group=placement_group) + + @abstractmethod + def get_node_to_compact( + self, allow_new_compaction: bool + ) -> Optional[Tuple[str, float]]: + """Returns a node ID to be compacted and a compaction deadlne.""" + raise NotImplementedError + + +class DefaultDeploymentScheduler(DeploymentScheduler): + def schedule( + self, + upscales: Dict[DeploymentID, List[ReplicaSchedulingRequest]], + downscales: Dict[DeploymentID, DeploymentDownscaleRequest], + ) -> Dict[DeploymentID, Set[ReplicaID]]: + """Called for each update cycle to do batch scheduling. + + Args: + upscales: a dict of deployment name to a list of replicas to schedule. + downscales: a dict of deployment name to a downscale request. + + Returns: + The IDs of replicas to stop for each deployment. + """ + for upscale in upscales.values(): + for scheduling_request in upscale: + replica_id = scheduling_request.replica_id + deployment_id = replica_id.deployment_id + self._pending_replicas[deployment_id][replica_id] = scheduling_request + + non_strict_pack_pgs_exist = any( + d.is_non_strict_pack_pg() for d in self._deployments.values() + ) + # Schedule replicas using compact strategy. + if RAY_SERVE_USE_COMPACT_SCHEDULING_STRATEGY and not non_strict_pack_pgs_exist: + # Flatten dict of deployment replicas into all replicas, + # then sort by decreasing resource size + all_scheduling_requests = sorted( + _flatten(self._pending_replicas).values(), + key=lambda r: r.required_resources, + reverse=True, + ) + + # Schedule each replica + for scheduling_request in all_scheduling_requests: + target_node = self._find_best_available_node( + scheduling_request.required_resources, + self._get_available_resources_per_node(), + ) + + self._schedule_replica( + scheduling_request, + default_scheduling_strategy="DEFAULT", + target_node_id=target_node, + ) + + else: + for pending_replicas in self._pending_replicas.values(): + if not pending_replicas: + continue + + for scheduling_request in list(pending_replicas.values()): + self._schedule_replica( + scheduling_request=scheduling_request, + default_scheduling_strategy="SPREAD", + ) + + deployment_to_replicas_to_stop = {} + for downscale in downscales.values(): + deployment_to_replicas_to_stop[ + downscale.deployment_id + ] = self._get_replicas_to_stop( + downscale.deployment_id, downscale.num_to_stop + ) + + return deployment_to_replicas_to_stop + + def _get_replicas_to_stop( + self, deployment_id: DeploymentID, max_num_to_stop: int + ) -> Set[ReplicaID]: + """Prioritize replicas running on a node with fewest replicas of + all deployments. + + This algorithm helps to scale down more intelligently because it can + relinquish nodes faster. Note that this algorithm doesn't consider + other non-serve actors on the same node. See more at + https://github.com/ray-project/ray/issues/20599. + """ + replicas_to_stop = set() + + # Replicas not in running state don't have node id. + # We will prioritize those first. + pending_launching_recovering_replicas = set().union( + self._pending_replicas[deployment_id].keys(), + self._launching_replicas[deployment_id].keys(), + self._recovering_replicas[deployment_id], + ) + for ( + pending_launching_recovering_replica + ) in pending_launching_recovering_replicas: + if len(replicas_to_stop) == max_num_to_stop: + return replicas_to_stop + else: + replicas_to_stop.add(pending_launching_recovering_replica) + + node_to_running_replicas_of_target_deployment = ( + self._get_node_to_running_replicas(deployment_id) + ) + node_to_running_replicas_of_all_deployments = ( + self._get_node_to_running_replicas() + ) + + # Replicas on the head node has the lowest priority for downscaling + # since we cannot relinquish the head node. + def key(node_and_num_running_replicas_of_all_deployments): + return ( + len(node_and_num_running_replicas_of_all_deployments[1]) + if node_and_num_running_replicas_of_all_deployments[0] + != self._head_node_id + else sys.maxsize + ) + + for node_id, _ in sorted( + node_to_running_replicas_of_all_deployments.items(), key=key + ): + if node_id not in node_to_running_replicas_of_target_deployment: + continue + for running_replica in node_to_running_replicas_of_target_deployment[ + node_id + ]: + if len(replicas_to_stop) == max_num_to_stop: + return replicas_to_stop + else: + replicas_to_stop.add(running_replica) + + return replicas_to_stop + + def _find_best_available_node( + self, + required_resources: Resources, + available_resources_per_node: Dict[str, Resources], + ) -> Optional[str]: + """Chooses best available node to schedule the required resources. + + If there are available nodes, returns the node ID of the best + available node, minimizing fragmentation. Prefers non-idle nodes + over idle nodes. + """ + + node_to_running_replicas = self._get_node_to_running_replicas() + + non_idle_nodes = { + node_id: res + for node_id, res in available_resources_per_node.items() + if len(node_to_running_replicas.get(node_id, set())) > 0 + } + idle_nodes = { + node_id: res + for node_id, res in available_resources_per_node.items() + if len(node_to_running_replicas.get(node_id, set())) == 0 + } + + # 1. Prefer non-idle nodes + chosen_node = self._best_fit_node(required_resources, non_idle_nodes) + if chosen_node: + return chosen_node + + # 2. Consider idle nodes last + chosen_node = self._best_fit_node(required_resources, idle_nodes) + if chosen_node: + return chosen_node + + def get_node_to_compact( + self, allow_new_compaction: bool + ) -> Optional[Tuple[str, float]]: + return None diff --git a/lib/python3.12/site-packages/ray/serve/_private/deployment_state.py b/lib/python3.12/site-packages/ray/serve/_private/deployment_state.py new file mode 100644 index 0000000000000000000000000000000000000000..4b96833aff6a21d4a4261187ec25cd55ae00f536 --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/_private/deployment_state.py @@ -0,0 +1,3747 @@ +import json +import logging +import math +import os +import random +import time +import traceback +from collections import defaultdict +from copy import copy +from dataclasses import dataclass +from enum import Enum +from typing import Any, Callable, Dict, List, Optional, Set, Tuple + +import ray +from ray import ObjectRef, cloudpickle +from ray._common import ray_constants +from ray.actor import ActorHandle +from ray.exceptions import RayActorError, RayError, RayTaskError, RuntimeEnvSetupError +from ray.serve import metrics +from ray.serve._private import default_impl +from ray.serve._private.autoscaling_state import AutoscalingStateManager +from ray.serve._private.cluster_node_info_cache import ClusterNodeInfoCache +from ray.serve._private.common import ( + DeploymentID, + DeploymentStatus, + DeploymentStatusInfo, + DeploymentStatusInternalTrigger, + DeploymentStatusTrigger, + DeploymentTargetInfo, + Duration, + ReplicaID, + ReplicaState, + RequestRoutingInfo, + RunningReplicaInfo, +) +from ray.serve._private.config import DeploymentConfig +from ray.serve._private.constants import ( + MAX_DEPLOYMENT_CONSTRUCTOR_RETRY_COUNT, + MAX_PER_REPLICA_RETRY_COUNT, + RAY_SERVE_ENABLE_TASK_EVENTS, + RAY_SERVE_FAIL_ON_RANK_ERROR, + RAY_SERVE_FORCE_STOP_UNHEALTHY_REPLICAS, + RAY_SERVE_USE_COMPACT_SCHEDULING_STRATEGY, + REPLICA_HEALTH_CHECK_UNHEALTHY_THRESHOLD, + SERVE_LOGGER_NAME, + SERVE_NAMESPACE, +) +from ray.serve._private.deployment_info import DeploymentInfo +from ray.serve._private.deployment_scheduler import ( + DeploymentDownscaleRequest, + DeploymentScheduler, + ReplicaSchedulingRequest, + ReplicaSchedulingRequestStatus, + SpreadDeploymentSchedulingPolicy, +) +from ray.serve._private.exceptions import DeploymentIsBeingDeletedError +from ray.serve._private.long_poll import LongPollHost, LongPollNamespace +from ray.serve._private.storage.kv_store import KVStoreBase +from ray.serve._private.usage import ServeUsageTag +from ray.serve._private.utils import ( + JavaActorHandleProxy, + check_obj_ref_ready_nowait, + get_capacity_adjusted_num_replicas, + get_random_string, + msgpack_deserialize, + msgpack_serialize, +) +from ray.serve._private.version import DeploymentVersion +from ray.serve.generated.serve_pb2 import DeploymentLanguage +from ray.serve.schema import ( + DeploymentDetails, + ReplicaDetails, + ReplicaRank, + _deployment_info_to_schema, +) +from ray.util.placement_group import PlacementGroup + +logger = logging.getLogger(SERVE_LOGGER_NAME) + + +class ReplicaStartupStatus(Enum): + PENDING_ALLOCATION = 1 + PENDING_INITIALIZATION = 2 + SUCCEEDED = 3 + FAILED = 4 + + +class ReplicaHealthCheckResponse(Enum): + NONE = 1 + SUCCEEDED = 2 + APP_FAILURE = 3 + ACTOR_CRASHED = 4 + + +@dataclass +class DeploymentTargetState: + """The current goal state for a deployment. + + info: contains the information needed to initialize a replica. + target_num_replicas: the number of replicas to run. This should already + be adjusted by the target_capacity. + version: the goal version of the deployment. + deleting: whether the deployment is being deleted. + """ + + info: Optional[DeploymentInfo] + target_num_replicas: int + version: Optional[DeploymentVersion] + deleting: bool + + @classmethod + def default(cls) -> "DeploymentTargetState": + return cls(None, -1, None, False) + + @classmethod + def create( + cls, + info: DeploymentInfo, + target_num_replicas: int, + *, + deleting: bool = False, + ) -> "DeploymentTargetState": + if deleting: + if target_num_replicas != 0: + raise ValueError( + "target_num_replicas must be 0 when setting target state " + f"to deleting. Got {target_num_replicas} instead." + ) + + version = DeploymentVersion( + info.version, + deployment_config=info.deployment_config, + ray_actor_options=info.replica_config.ray_actor_options, + placement_group_bundles=info.replica_config.placement_group_bundles, + placement_group_strategy=info.replica_config.placement_group_strategy, + max_replicas_per_node=info.replica_config.max_replicas_per_node, + route_prefix=info.route_prefix, + ) + + return cls(info, target_num_replicas, version, deleting) + + def is_scaled_copy_of(self, other_target_state: "DeploymentTargetState") -> bool: + """Checks if this target state is a scaled copy of another target state. + + A target state is a scaled copy of another target state if all + configurable info is identical, other than target_num_replicas. + + Returns: True if this target state contains a non-None DeploymentInfo + and is a scaled copy of the other target state. + """ + + if other_target_state.info is None: + return False + + return all( + [ + self.info.replica_config.ray_actor_options + == other_target_state.info.replica_config.ray_actor_options, + self.info.replica_config.placement_group_bundles + == other_target_state.info.replica_config.placement_group_bundles, + self.info.replica_config.placement_group_strategy + == other_target_state.info.replica_config.placement_group_strategy, + self.info.replica_config.max_replicas_per_node + == other_target_state.info.replica_config.max_replicas_per_node, + self.info.deployment_config.dict(exclude={"num_replicas"}) + == other_target_state.info.deployment_config.dict( + exclude={"num_replicas"} + ), + # TODO(zcin): version can be None, this is from an outdated codepath. + # We should remove outdated code, so version can never be None. + self.version, + self.version == other_target_state.version, + ] + ) + + +@dataclass +class DeploymentStateUpdateResult: + deleted: bool + any_replicas_recovering: bool + upscale: List[ReplicaSchedulingRequest] + downscale: Optional[DeploymentDownscaleRequest] + + +CHECKPOINT_KEY = "serve-deployment-state-checkpoint" +SLOW_STARTUP_WARNING_S = int(os.environ.get("SERVE_SLOW_STARTUP_WARNING_S", 30)) +SLOW_STARTUP_WARNING_PERIOD_S = int( + os.environ.get("SERVE_SLOW_STARTUP_WARNING_PERIOD_S", 30) +) + +ALL_REPLICA_STATES = list(ReplicaState) +_SCALING_LOG_ENABLED = os.environ.get("SERVE_ENABLE_SCALING_LOG", "0") != "0" +# Feature flag to disable forcibly shutting down replicas. +RAY_SERVE_DISABLE_SHUTTING_DOWN_INGRESS_REPLICAS_FORCEFULLY = ( + os.environ.get("RAY_SERVE_DISABLE_SHUTTING_DOWN_INGRESS_REPLICAS_FORCEFULLY", "0") + == "1" +) + + +def print_verbose_scaling_log(): + assert _SCALING_LOG_ENABLED + + log_path = "/tmp/ray/session_latest/logs/monitor.log" + last_n_lines = 50 + autoscaler_log_last_n_lines = [] + if os.path.exists(log_path): + with open(log_path) as f: + autoscaler_log_last_n_lines = f.readlines()[-last_n_lines:] + + debug_info = { + "nodes": ray.nodes(), + "available_resources": ray.available_resources(), + "total_resources": ray.cluster_resources(), + "autoscaler_logs": autoscaler_log_last_n_lines, + } + logger.error(f"Scaling information\n{json.dumps(debug_info, indent=2)}") + + +class ActorReplicaWrapper: + """Wraps a Ray actor for a deployment replica. + + This is primarily defined so that we can mock out actual Ray operations + for unit testing. + + *All Ray API calls should be made here, not in DeploymentState.* + """ + + def __init__( + self, + replica_id: ReplicaID, + version: DeploymentVersion, + ): + self._replica_id = replica_id + self._deployment_id = replica_id.deployment_id + self._actor_name = replica_id.to_full_id_str() + + # Populated in either self.start() or self.recover() + self._allocated_obj_ref: ObjectRef = None + self._ready_obj_ref: ObjectRef = None + + self._actor_resources: Dict[str, float] = None + # If the replica is being started, this will be the true version + # If the replica is being recovered, this will be the target + # version, which may be inconsistent with the actual replica + # version. If so, the actual version will be updated later after + # recover() and check_ready() + self._version: DeploymentVersion = version + self._healthy: bool = True + self._health_check_ref: Optional[ObjectRef] = None + self._last_health_check_time: float = 0.0 + self._consecutive_health_check_failures = 0 + self._initialization_latency_s: Optional[float] = None + self._internal_grpc_port: Optional[int] = None + self._docs_path: Optional[str] = None + self._route_patterns: Optional[List[str]] = None + # Rank assigned to the replica. + self._rank: Optional[ReplicaRank] = None + # Populated in `on_scheduled` or `recover`. + self._actor_handle: ActorHandle = None + self._placement_group: PlacementGroup = None + + # Populated after replica is allocated. + self._pid: int = None + self._actor_id: str = None + self._worker_id: str = None + self._node_id: str = None + self._node_ip: str = None + self._node_instance_id: str = None + self._log_file_path: str = None + self._http_port: int = None + self._grpc_port: int = None + + # Populated in self.stop(). + self._graceful_shutdown_ref: ObjectRef = None + + # todo: will be confused with deployment_config.is_cross_language + self._is_cross_language = False + self._deployment_is_cross_language = False + self._routing_stats: Dict[str, Any] = {} + self._record_routing_stats_ref: Optional[ObjectRef] = None + self._last_record_routing_stats_time: float = 0.0 + self._ingress: bool = False + + # Outbound deployments polling state + self._outbound_deployments: Optional[List[DeploymentID]] = None + + @property + def replica_id(self) -> str: + return self._replica_id + + @property + def deployment_name(self) -> str: + return self._deployment_id.name + + @property + def rank(self) -> Optional[ReplicaRank]: + return self._rank + + @property + def app_name(self) -> str: + return self._deployment_id.app_name + + @property + def is_cross_language(self) -> bool: + return self._is_cross_language + + @property + def actor_handle(self) -> Optional[ActorHandle]: + if not self._actor_handle: + try: + self._actor_handle = ray.get_actor( + self._actor_name, namespace=SERVE_NAMESPACE + ) + except ValueError: + self._actor_handle = None + + if self._is_cross_language: + assert isinstance(self._actor_handle, JavaActorHandleProxy) + return self._actor_handle.handle + + return self._actor_handle + + @property + def placement_group_bundles(self) -> Optional[List[Dict[str, float]]]: + if not self._placement_group: + return None + + return self._placement_group.bundle_specs + + @property + def version(self) -> DeploymentVersion: + """Replica version. This can be incorrect during state recovery. + + If the controller crashes and the deployment state is being + recovered, this will temporarily be the deployment-wide target + version, which may be inconsistent with the actual version + running on the replica actor. If so, the actual version will be + updated when the replica transitions from RECOVERING -> RUNNING + """ + return self._version + + @property + def deployment_config(self) -> DeploymentConfig: + """Deployment config. This can return an incorrect config during state recovery. + + If the controller hasn't yet recovered the up-to-date version + from the running replica actor, this property will return the + current target config for the deployment. + """ + return self._version.deployment_config + + @property + def docs_path(self) -> Optional[str]: + return self._docs_path + + @property + def route_patterns(self) -> Optional[List[str]]: + return self._route_patterns + + @property + def max_ongoing_requests(self) -> int: + return self.deployment_config.max_ongoing_requests + + @property + def max_queued_requests(self) -> int: + return self.deployment_config.max_queued_requests + + @property + def graceful_shutdown_timeout_s(self) -> float: + return self.deployment_config.graceful_shutdown_timeout_s + + @property + def health_check_period_s(self) -> float: + return self.deployment_config.health_check_period_s + + @property + def health_check_timeout_s(self) -> float: + return self.deployment_config.health_check_timeout_s + + @property + def http_port(self) -> Optional[int]: + return self._http_port + + @property + def grpc_port(self) -> Optional[int]: + return self._grpc_port + + @property + def request_routing_stats_period_s(self) -> float: + return ( + self.deployment_config.request_router_config.request_routing_stats_period_s + ) + + @property + def request_routing_stats_timeout_s(self) -> float: + return ( + self.deployment_config.request_router_config.request_routing_stats_timeout_s + ) + + @property + def pid(self) -> Optional[int]: + """Returns the pid of the actor, None if not started.""" + return self._pid + + @property + def actor_id(self) -> Optional[str]: + """Returns the actor id, None if not started.""" + return self._actor_id + + @property + def worker_id(self) -> Optional[str]: + """Returns the worker id, None if not started.""" + return self._worker_id + + @property + def node_id(self) -> Optional[str]: + """Returns the node id of the actor, None if not placed.""" + return self._node_id + + @property + def node_ip(self) -> Optional[str]: + """Returns the node ip of the actor, None if not placed.""" + return self._node_ip + + @property + def node_instance_id(self) -> Optional[str]: + """Returns the node instance id of the actor, None if not placed.""" + return self._node_instance_id + + @property + def log_file_path(self) -> Optional[str]: + """Returns the relative log file path of the actor, None if not placed.""" + return self._log_file_path + + @property + def initialization_latency_s(self) -> Optional[float]: + """Returns the initialization latency for the replica actor. + + Returns None if the replica hasn't started yet. + + Note: this value isn't checkpointed, so if the controller restarts, + this value goes back to None. + """ + + return self._initialization_latency_s + + def start( + self, deployment_info: DeploymentInfo, rank: ReplicaRank + ) -> ReplicaSchedulingRequest: + """Start the current DeploymentReplica instance. + + The replica will be in the STARTING and PENDING_ALLOCATION states + until the deployment scheduler schedules the underlying actor. + """ + self._rank = rank # Store the rank assigned to this replica + self._actor_resources = deployment_info.replica_config.resource_dict + self._ingress = deployment_info.ingress + # it is currently not possible to create a placement group + # with no resources (https://github.com/ray-project/ray/issues/20401) + self._deployment_is_cross_language = ( + deployment_info.deployment_config.is_cross_language + ) + + logger.info( + f"Starting {self.replica_id}.", + extra={"log_to_stderr": False}, + ) + + actor_def = deployment_info.actor_def + if ( + deployment_info.deployment_config.deployment_language + == DeploymentLanguage.PYTHON + ): + if deployment_info.replica_config.serialized_init_args is None: + serialized_init_args = cloudpickle.dumps(()) + else: + serialized_init_args = ( + cloudpickle.dumps( + msgpack_deserialize( + deployment_info.replica_config.serialized_init_args + ) + ) + if self._deployment_is_cross_language + else deployment_info.replica_config.serialized_init_args + ) + init_args = ( + self.replica_id, + cloudpickle.dumps(deployment_info.replica_config.deployment_def) + if self._deployment_is_cross_language + else deployment_info.replica_config.serialized_deployment_def, + serialized_init_args, + deployment_info.replica_config.serialized_init_kwargs + if deployment_info.replica_config.serialized_init_kwargs + else cloudpickle.dumps({}), + deployment_info.deployment_config.to_proto_bytes(), + self._version, + deployment_info.ingress, + deployment_info.route_prefix, + rank, + ) + # TODO(simon): unify the constructor arguments across language + elif ( + deployment_info.deployment_config.deployment_language + == DeploymentLanguage.JAVA + ): + self._is_cross_language = True + actor_def = ray.cross_language.java_actor_class( + "io.ray.serve.replica.RayServeWrappedReplica" + ) + init_args = ( + # String deploymentName, + self.deployment_name, + # String replicaID, + self.replica_id.to_full_id_str(), + # String deploymentDef + deployment_info.replica_config.deployment_def_name, + # byte[] initArgsbytes + msgpack_serialize( + cloudpickle.loads( + deployment_info.replica_config.serialized_init_args + ) + ) + if self._deployment_is_cross_language + else deployment_info.replica_config.serialized_init_args, + # byte[] deploymentConfigBytes, + deployment_info.deployment_config.to_proto_bytes(), + # byte[] deploymentVersionBytes, + self._version.to_proto().SerializeToString(), + # String controllerName + # String appName + self.app_name, + ) + + actor_options = { + "name": self._actor_name, + "namespace": SERVE_NAMESPACE, + "lifetime": "detached", + "enable_task_events": RAY_SERVE_ENABLE_TASK_EVENTS, + } + actor_options.update(deployment_info.replica_config.ray_actor_options) + + # A replica's default `max_concurrency` value can prevent it from + # respecting the configured `max_ongoing_requests`. To avoid this + # unintentional behavior, use `max_ongoing_requests` to override + # the Actor's `max_concurrency` if it is larger. + if ( + deployment_info.deployment_config.max_ongoing_requests + > ray_constants.DEFAULT_MAX_CONCURRENCY_ASYNC + ): + actor_options[ + "max_concurrency" + ] = deployment_info.deployment_config.max_ongoing_requests + + return ReplicaSchedulingRequest( + replica_id=self.replica_id, + actor_def=actor_def, + actor_resources=self._actor_resources, + actor_options=actor_options, + actor_init_args=init_args, + placement_group_bundles=( + deployment_info.replica_config.placement_group_bundles + ), + placement_group_strategy=( + deployment_info.replica_config.placement_group_strategy + ), + max_replicas_per_node=( + deployment_info.replica_config.max_replicas_per_node + ), + on_scheduled=self.on_scheduled, + ) + + def on_scheduled( + self, + actor_handle: ActorHandle, + placement_group: Optional[PlacementGroup] = None, + ): + self._actor_handle = actor_handle + self._placement_group = placement_group + + # Perform auto method name translation for java handles. + # See https://github.com/ray-project/ray/issues/21474 + deployment_config = copy(self._version.deployment_config) + deployment_config.user_config = self._format_user_config( + deployment_config.user_config + ) + if self._is_cross_language: + self._actor_handle = JavaActorHandleProxy(self._actor_handle) + self._allocated_obj_ref = self._actor_handle.is_allocated.remote() + self._ready_obj_ref = self._actor_handle.is_initialized.remote( + deployment_config.to_proto_bytes() + ) + else: + self._allocated_obj_ref = self._actor_handle.is_allocated.remote() + replica_ready_check_func = self._actor_handle.initialize_and_get_metadata + self._ready_obj_ref = replica_ready_check_func.remote( + deployment_config, + # Ensure that `is_allocated` will execute + # before `initialize_and_get_metadata`, + # because `initialize_and_get_metadata` runs + # user code that could block the replica + # asyncio loop. If that happens before `is_allocated` is executed, + # the `is_allocated` call won't be able to run. + self._allocated_obj_ref, + ) + + def _format_user_config(self, user_config: Any): + temp = copy(user_config) + if user_config is not None and self._deployment_is_cross_language: + if self._is_cross_language: + temp = msgpack_serialize(temp) + else: + temp = msgpack_deserialize(temp) + return temp + + def reconfigure(self, version: DeploymentVersion, rank: ReplicaRank) -> bool: + """ + Update replica version. Also, updates the deployment config on the actor + behind this DeploymentReplica instance if necessary. + + Returns: whether the actor is being updated. + """ + updating = False + + # Determine if we need heavyweight reconfiguration + # vs lightweight updates + needs_actor_reconfigure = self._version.requires_actor_reconfigure(version) + has_rank_changes = self._rank != rank + + if needs_actor_reconfigure or has_rank_changes: + # Call into replica actor reconfigure() with updated user config and + # graceful_shutdown_wait_loop_s + # Setting updating=True because we want to transition to UPDATING state + # when rank is updated or deployment config changes. + updating = True + deployment_config = copy(version.deployment_config) + deployment_config.user_config = self._format_user_config( + deployment_config.user_config + ) + self._ready_obj_ref = self._actor_handle.reconfigure.remote( + deployment_config, + rank, + version.route_prefix, + ) + + self._version = version + self._rank = rank + return updating + + def recover(self) -> bool: + """Recover replica version from a live replica actor. + + When controller dies, the deployment state loses the info on the version that's + running on each individual replica actor, so as part of the recovery process, we + need to recover the version that is running on the replica actor. + + Also confirm that actor is allocated and initialized before marking as running. + + Returns: False if the replica actor is no longer alive; the + actor could have been killed in the time between when the + controller fetching all Serve actors in the cluster and when + the controller tries to recover it. Otherwise, return True. + """ + logger.info(f"Recovering {self.replica_id}.") + try: + self._actor_handle = ray.get_actor( + self._actor_name, namespace=SERVE_NAMESPACE + ) + except ValueError: + logger.warning( + f"Failed to get handle to replica {self._actor_name} " + "during controller recovery. Marking as dead." + ) + return False + + try: + self._placement_group = ray.util.get_placement_group( + self._actor_name, + ) + except ValueError: + # ValueError is raised if the placement group does not exist. + self._placement_group = None + + # Re-fetch initialization proof + self._allocated_obj_ref = self._actor_handle.is_allocated.remote() + + # Running actor handle already has all info needed, thus successful + # starting simply means retrieving replica version hash from actor + if self._is_cross_language: + self._ready_obj_ref = self._actor_handle.check_health.remote() + else: + self._ready_obj_ref = ( + self._actor_handle.initialize_and_get_metadata.remote() + ) + + return True + + def check_ready(self) -> Tuple[ReplicaStartupStatus, Optional[str]]: + """ + Check if current replica has started by making ray API calls on + relevant actor / object ref. + + Replica initialization calls __init__(), reconfigure(), and check_health(). + + Returns: + state (ReplicaStartupStatus): + PENDING_ALLOCATION: replica is waiting for a worker to start + PENDING_INITIALIZATION: replica initialization hasn't finished. + FAILED: replica initialization failed. + SUCCEEDED: replica initialization succeeded. + error_msg: + None: for PENDING_ALLOCATION, PENDING_INITIALIZATION or SUCCEEDED states + str: for FAILED state + """ + + # Check whether the replica has been allocated. + if self._allocated_obj_ref is None or not check_obj_ref_ready_nowait( + self._allocated_obj_ref + ): + return ReplicaStartupStatus.PENDING_ALLOCATION, None + + if not self._is_cross_language: + try: + ( + self._pid, + self._actor_id, + self._worker_id, + self._node_id, + self._node_ip, + self._node_instance_id, + self._log_file_path, + ) = ray.get(self._allocated_obj_ref) + except RayTaskError as e: + logger.exception( + f"Exception in {self._replica_id}, the replica will be stopped." + ) + return ReplicaStartupStatus.FAILED, str(e.as_instanceof_cause()) + except RuntimeEnvSetupError as e: + msg = f"Exception when allocating {self._replica_id}: {str(e)}" + logger.exception(msg) + return ReplicaStartupStatus.FAILED, msg + except Exception: + msg = ( + f"Exception when allocating {self._replica_id}:\n" + + traceback.format_exc() + ) + logger.exception(msg) + return ReplicaStartupStatus.FAILED, msg + + # Check whether replica initialization has completed. + replica_ready = check_obj_ref_ready_nowait(self._ready_obj_ref) + # In case of deployment constructor failure, ray.get will help to + # surface exception to each update() cycle. + if not replica_ready: + return ReplicaStartupStatus.PENDING_INITIALIZATION, None + else: + try: + # TODO(simon): fully implement reconfigure for Java replicas. + if self._is_cross_language: + return ReplicaStartupStatus.SUCCEEDED, None + + # todo: The replica's userconfig whitch java client created + # is different from the controller's userconfig + if not self._deployment_is_cross_language: + # This should only update version if the replica is being recovered. + # If this is checking on a replica that is newly started, this + # should return a version that is identical to what's already stored + ( + _, + self._version, + self._initialization_latency_s, + self._internal_grpc_port, + self._docs_path, + self._http_port, + self._grpc_port, + self._rank, + self._route_patterns, + self._outbound_deployments, + ) = ray.get(self._ready_obj_ref) + except RayTaskError as e: + logger.exception( + f"Exception in {self._replica_id}, the replica will be stopped." + ) + # NOTE(zcin): we should use str(e) instead of traceback.format_exc() + # here because the full details of the error is not displayed properly + # with traceback.format_exc(). + return ReplicaStartupStatus.FAILED, str(e.as_instanceof_cause()) + except Exception as e: + logger.exception( + f"Exception in {self._replica_id}, the replica will be stopped." + ) + return ReplicaStartupStatus.FAILED, repr(e) + + return ReplicaStartupStatus.SUCCEEDED, None + + @property + def actor_resources(self) -> Optional[Dict[str, float]]: + return self._actor_resources + + @property + def available_resources(self) -> Dict[str, float]: + return ray.available_resources() + + def graceful_stop(self) -> Duration: + """Request the actor to exit gracefully. + + Returns the timeout after which to kill the actor. + """ + try: + handle = ray.get_actor(self._actor_name, namespace=SERVE_NAMESPACE) + if self._is_cross_language: + handle = JavaActorHandleProxy(handle) + self._graceful_shutdown_ref = handle.perform_graceful_shutdown.remote() + except ValueError: + # ValueError thrown from ray.get_actor means actor has already been deleted. + pass + + return self.graceful_shutdown_timeout_s + + def check_stopped(self) -> bool: + """Check if the actor has exited.""" + try: + handle = ray.get_actor(self._actor_name, namespace=SERVE_NAMESPACE) + stopped = check_obj_ref_ready_nowait(self._graceful_shutdown_ref) + if stopped: + try: + ray.get(self._graceful_shutdown_ref) + except Exception: + logger.exception( + "Exception when trying to gracefully shutdown replica:\n" + + traceback.format_exc() + ) + + ray.kill(handle, no_restart=True) + except ValueError: + # ValueError thrown from ray.get_actor means actor has already been deleted. + stopped = True + finally: + # Remove the placement group both if the actor has already been deleted or + # it was just killed above. + if stopped and self._placement_group is not None: + ray.util.remove_placement_group(self._placement_group) + + return stopped + + def _check_active_health_check(self) -> ReplicaHealthCheckResponse: + """Check the active health check (if any). + + self._health_check_ref will be reset to `None` when the active health + check is deemed to have succeeded or failed. This method *does not* + start a new health check, that's up to the caller. + + Returns: + - NONE if there's no active health check, or it hasn't returned + yet and the timeout is not up. + - SUCCEEDED if the active health check succeeded. + - APP_FAILURE if the active health check failed (or didn't return + before the timeout). + - ACTOR_CRASHED if the underlying actor crashed. + """ + if self._health_check_ref is None: + # There is no outstanding health check. + response = ReplicaHealthCheckResponse.NONE + elif check_obj_ref_ready_nowait(self._health_check_ref): + # Object ref is ready, ray.get it to check for exceptions. + try: + ray.get(self._health_check_ref) + # Health check succeeded without exception. + response = ReplicaHealthCheckResponse.SUCCEEDED + except RayActorError: + # Health check failed due to actor crashing. + response = ReplicaHealthCheckResponse.ACTOR_CRASHED + except RayError as e: + # Health check failed due to application-level exception. + logger.warning(f"Health check for {self._replica_id} failed: {e}") + response = ReplicaHealthCheckResponse.APP_FAILURE + elif time.time() - self._last_health_check_time > self.health_check_timeout_s: + # Health check hasn't returned and the timeout is up, consider it failed. + logger.warning( + "Didn't receive health check response for replica " + f"{self._replica_id} after " + f"{self.health_check_timeout_s}s, marking it unhealthy." + ) + response = ReplicaHealthCheckResponse.APP_FAILURE + else: + # Health check hasn't returned and the timeout isn't up yet. + response = ReplicaHealthCheckResponse.NONE + + if response is not ReplicaHealthCheckResponse.NONE: + self._health_check_ref = None + + return response + + def _should_start_new_health_check(self) -> bool: + """Determines if a new health check should be kicked off. + + A health check will be started if: + 1) There is not already an active health check. + 2) It has been more than health_check_period_s since the + previous health check was *started*. + + This assumes that self._health_check_ref is reset to `None` when an + active health check succeeds or fails (due to returning or timeout). + """ + if self._health_check_ref is not None: + # There's already an active health check. + return False + + # If there's no active health check, kick off another and reset + # the timer if it's been long enough since the last health + # check. Add some randomness to avoid synchronizing across all + # replicas. + time_since_last = time.time() - self._last_health_check_time + randomized_period = self.health_check_period_s * random.uniform(0.9, 1.1) + return time_since_last > randomized_period + + def _should_record_routing_stats(self) -> bool: + """Determines if a new record routing stats should be kicked off. + + A record routing stats will be started if: + 1) There is not already an active record routing stats. + 2) It has been more than request_routing_stats_period_s since + the previous record routing stats was *started*. + + This assumes that self._record_routing_stats_ref is reset to `None` + when an active record routing stats succeeds or fails (due to + returning or timeout). + """ + if self._record_routing_stats_ref is not None: + # There's already an active record routing stats. + return False + + # If there's no active record routing stats, kick off another and + # reset the timer if it's been long enough since the last record + # routing stats. Add some randomness to avoid synchronizing across + # all replicas. + time_since_last = time.time() - self._last_record_routing_stats_time + randomized_period = self.request_routing_stats_period_s * random.uniform( + 0.9, 1.1 + ) + return time_since_last > randomized_period + + def check_health(self) -> bool: + """Check if the actor is healthy. + + self._healthy should *only* be modified in this method. + + This is responsible for: + 1) Checking the outstanding health check (if any). + 2) Determining the replica health based on the health check results. + 3) Kicking off a new health check if needed. + """ + response: ReplicaHealthCheckResponse = self._check_active_health_check() + if response is ReplicaHealthCheckResponse.NONE: + # No info; don't update replica health. + pass + elif response is ReplicaHealthCheckResponse.SUCCEEDED: + # Health check succeeded. Reset the consecutive failure counter + # and mark the replica healthy. + if self._consecutive_health_check_failures > 0: + logger.info( + f"{self._replica_id} passed the health check after " + f"{self._consecutive_health_check_failures} consecutive failures." + ) + self._consecutive_health_check_failures = 0 + self._healthy = True + elif response is ReplicaHealthCheckResponse.APP_FAILURE: + # Health check failed. If it has failed more than N times in a row, + # mark the replica unhealthy. + self._consecutive_health_check_failures += 1 + if ( + self._consecutive_health_check_failures + >= REPLICA_HEALTH_CHECK_UNHEALTHY_THRESHOLD + ): + logger.warning( + f"Replica {self._replica_id} failed the health " + f"check {self._consecutive_health_check_failures} " + "times in a row, marking it unhealthy." + ) + self._healthy = False + elif response is ReplicaHealthCheckResponse.ACTOR_CRASHED: + # Actor crashed, mark the replica unhealthy immediately. + logger.warning( + f"Actor for {self._replica_id} crashed, marking " + "it unhealthy immediately." + ) + self._healthy = False + else: + assert False, f"Unknown response type: {response}." + + if self._should_start_new_health_check(): + self._last_health_check_time = time.time() + self._health_check_ref = self._actor_handle.check_health.remote() + + return self._healthy + + def get_routing_stats(self) -> Dict[str, Any]: + """Get the routing stats for the replica.""" + if self._record_routing_stats_ref is None: + # There's no active record routing stats. + pass + elif check_obj_ref_ready_nowait(self._record_routing_stats_ref): + # Object ref is ready, ray.get it to check for exceptions. + try: + self._routing_stats = ray.get(self._record_routing_stats_ref) + except Exception: + logger.exception( + "Exception when trying to get routing stats:\n" + + traceback.format_exc() + ) + self._record_routing_stats_ref = None + elif ( + time.time() - self._last_record_routing_stats_time + > self.request_routing_stats_timeout_s + ): + # Record routing stats hasn't returned and the timeout is up, retrying. + logger.warning( + "Didn't receive routing stats response for replica " + f"{self._replica_id} after " + f"{self.request_routing_stats_timeout_s}s, retrying." + ) + self._record_routing_stats_ref = None + + if self._should_record_routing_stats(): + self._last_record_routing_stats_time = time.time() + self._record_routing_stats_ref = ( + self._actor_handle.record_routing_stats.remote() + ) + + return self._routing_stats + + def force_stop(self, log_shutdown_message: bool = False): + """Force the actor to exit without shutting down gracefully.""" + if ( + self._ingress + and RAY_SERVE_DISABLE_SHUTTING_DOWN_INGRESS_REPLICAS_FORCEFULLY + ): + if log_shutdown_message: + logger.info( + f"{self.replica_id} did not shut down because it had not finished draining requests. " + "Going to wait until the draining is complete. You can force-stop the replica by " + "setting RAY_SERVE_DISABLE_SHUTTING_DOWN_INGRESS_REPLICAS_FORCEFULLY to 0." + ) + return + + try: + ray.kill(ray.get_actor(self._actor_name, namespace=SERVE_NAMESPACE)) + except ValueError: + pass + + def get_outbound_deployments(self) -> Optional[List[DeploymentID]]: + return self._outbound_deployments + + +class DeploymentReplica: + """Manages state transitions for deployment replicas. + + This is basically a checkpointable lightweight state machine. + """ + + def __init__( + self, + replica_id: ReplicaID, + version: DeploymentVersion, + ): + self._replica_id = replica_id + self._actor = ActorReplicaWrapper(replica_id, version) + self._start_time = None + self._actor_details = ReplicaDetails( + actor_name=replica_id.to_full_id_str(), + replica_id=self._replica_id.unique_id, + state=ReplicaState.STARTING, + start_time_s=0, + ) + self._multiplexed_model_ids: List[str] = [] + self._routing_stats: Dict[str, Any] = {} + self._logged_shutdown_message = False + + def get_running_replica_info( + self, cluster_node_info_cache: ClusterNodeInfoCache + ) -> RunningReplicaInfo: + return RunningReplicaInfo( + replica_id=self._replica_id, + node_id=self.actor_node_id, + node_ip=self._actor.node_ip, + availability_zone=cluster_node_info_cache.get_node_az(self.actor_node_id), + actor_name=self._actor._actor_name, + max_ongoing_requests=self._actor.max_ongoing_requests, + is_cross_language=self._actor.is_cross_language, + multiplexed_model_ids=self.multiplexed_model_ids, + routing_stats=self.routing_stats, + port=self._actor._internal_grpc_port, + ) + + def record_multiplexed_model_ids(self, multiplexed_model_ids: List[str]): + """Record the multiplexed model ids for this replica.""" + self._multiplexed_model_ids = multiplexed_model_ids + + def record_routing_stats(self, routing_stats: Optional[Dict[str, Any]]): + """Record the routing stats for this replica. + + Recording routing_stats as an empty dictionary is valid. But skip + update if the routing_stats is None. + """ + if routing_stats is not None: + self._routing_stats = routing_stats + + @property + def multiplexed_model_ids(self) -> List[str]: + return self._multiplexed_model_ids + + @property + def routing_stats(self) -> Dict[str, Any]: + return self._routing_stats + + @property + def actor_details(self) -> ReplicaDetails: + return self._actor_details + + @property + def replica_id(self) -> ReplicaID: + return self._replica_id + + @property + def deployment_name(self) -> str: + return self._replica_id.deployment_id.name + + @property + def app_name(self) -> str: + return self._replica_id.deployment_id.app_name + + @property + def version(self): + return self._actor.version + + @property + def docs_path(self) -> Optional[str]: + return self._actor.docs_path + + @property + def route_patterns(self) -> Optional[List[str]]: + return self._actor.route_patterns + + @property + def actor_id(self) -> str: + return self._actor.actor_id + + @property + def actor_handle(self) -> ActorHandle: + return self._actor.actor_handle + + @property + def actor_node_id(self) -> Optional[str]: + """Returns the node id of the actor, None if not placed.""" + return self._actor.node_id + + @property + def actor_http_port(self) -> Optional[int]: + return self._actor.http_port + + @property + def actor_grpc_port(self) -> Optional[int]: + return self._actor.grpc_port + + @property + def actor_pid(self) -> Optional[int]: + """Returns the node id of the actor, None if not placed.""" + return self._actor.pid + + @property + def initialization_latency_s(self) -> Optional[float]: + """Returns how long the replica took to initialize.""" + + return self._actor.initialization_latency_s + + def start( + self, deployment_info: DeploymentInfo, rank: ReplicaRank + ) -> ReplicaSchedulingRequest: + """ + Start a new actor for current DeploymentReplica instance. + """ + replica_scheduling_request = self._actor.start(deployment_info, rank=rank) + self._start_time = time.time() + self._logged_shutdown_message = False + self.update_actor_details(start_time_s=self._start_time) + return replica_scheduling_request + + def reconfigure( + self, + version: DeploymentVersion, + rank: ReplicaRank, + ) -> bool: + """ + Update replica version. Also, updates the deployment config on the actor + behind this DeploymentReplica instance if necessary. + + Returns: whether the actor is being updated. + """ + return self._actor.reconfigure(version, rank=rank) + + def recover(self) -> bool: + """ + Recover states in DeploymentReplica instance by fetching running actor + status + + Returns: False if the replica is no longer alive at the time + when this method is called. + """ + # If replica is no longer alive + if not self._actor.recover(): + return False + + self._start_time = time.time() + self.update_actor_details(start_time_s=self._start_time) + return True + + @property + def rank(self) -> Optional[ReplicaRank]: + """Get the rank assigned to the replica.""" + return self._actor.rank + + def check_started( + self, + ) -> Tuple[ReplicaStartupStatus, Optional[str], Optional[float]]: + """Check if the replica has started. If so, transition to RUNNING. + + Should handle the case where the replica has already stopped. + + Returns: + status: Most recent state of replica by + querying actor obj ref + """ + is_ready = self._actor.check_ready() + self.update_actor_details( + pid=self._actor.pid, + node_id=self._actor.node_id, + node_ip=self._actor.node_ip, + node_instance_id=self._actor.node_instance_id, + actor_id=self._actor.actor_id, + worker_id=self._actor.worker_id, + log_file_path=self._actor.log_file_path, + ) + + return is_ready + + def stop(self, graceful: bool = True) -> None: + """Stop the replica. + + Should handle the case where the replica is already stopped. + """ + state = self._actor_details.state + logger.info( + f"Stopping {self.replica_id} (currently {state}).", + extra={"log_to_stderr": False}, + ) + timeout_s = self._actor.graceful_stop() + if not graceful: + timeout_s = 0 + self._shutdown_deadline = time.time() + timeout_s + + def check_stopped(self) -> bool: + """Check if the replica has finished stopping.""" + if self._actor.check_stopped(): + return True + + timeout_passed = time.time() >= self._shutdown_deadline + if timeout_passed: + if ( + not self._logged_shutdown_message + and not RAY_SERVE_DISABLE_SHUTTING_DOWN_INGRESS_REPLICAS_FORCEFULLY + ): + logger.info( + f"{self.replica_id} did not shut down after grace " + "period, force-killing it. " + ) + + self._actor.force_stop( + log_shutdown_message=not self._logged_shutdown_message + ) + self._logged_shutdown_message = True + return False + + def check_health(self) -> bool: + """Check if the replica is healthy. + + Returns `True` if the replica is healthy, else `False`. + """ + return self._actor.check_health() + + def pull_routing_stats(self) -> Optional[Dict[str, Any]]: + """Get the latest response from the routing stats on the replica. + + Returns None if the replica is still calculating the stats. + """ + return self._actor.get_routing_stats() + + def update_state(self, state: ReplicaState) -> None: + """Updates state in actor details.""" + self.update_actor_details(state=state) + + def update_actor_details(self, **kwargs) -> None: + details_kwargs = self._actor_details.dict() + details_kwargs.update(kwargs) + self._actor_details = ReplicaDetails(**details_kwargs) + + def resource_requirements(self) -> Tuple[str, str]: + """Returns required and currently available resources. + + Only resources with nonzero requirements will be included in the + required dict and only resources in the required dict will be + included in the available dict (filtered for relevance). + """ + if self._actor.actor_resources is None: + return "UNKNOWN", "UNKNOWN" + + if self._actor.placement_group_bundles is not None: + required = self._actor.placement_group_bundles + else: + required = { + k: v + for k, v in self._actor.actor_resources.items() + if v is not None and v > 0 + } + + available = { + k: v for k, v in self._actor.available_resources.items() if k in required + } + + # Use json.dumps() instead of str() here to avoid double-quoting keys + # when dumping these objects. See + # https://github.com/ray-project/ray/issues/26210 for the issue. + return json.dumps(required), json.dumps(available) + + def get_outbound_deployments(self) -> Optional[List[DeploymentID]]: + return self._actor.get_outbound_deployments() + + +class ReplicaStateContainer: + """Container for mapping ReplicaStates to lists of DeploymentReplicas.""" + + def __init__(self): + self._replicas: Dict[ReplicaState, List[DeploymentReplica]] = defaultdict(list) + + def add(self, state: ReplicaState, replica: DeploymentReplica): + """Add the provided replica under the provided state. + + Args: + state: state to add the replica under. + replica: replica to add. + """ + assert isinstance(state, ReplicaState), f"Type: {type(state)}" + replica.update_state(state) + self._replicas[state].append(replica) + + def get( + self, states: Optional[List[ReplicaState]] = None + ) -> List[DeploymentReplica]: + """Get all replicas of the given states. + + This does not remove them from the container. Replicas are returned + in order of state as passed in. + + Args: + states: states to consider. If not specified, all replicas + are considered. + """ + if states is None: + states = ALL_REPLICA_STATES + + assert isinstance(states, list) + + return sum((self._replicas[state] for state in states), []) + + def pop( + self, + exclude_version: Optional[DeploymentVersion] = None, + states: Optional[List[ReplicaState]] = None, + max_replicas: Optional[int] = math.inf, + ) -> List[DeploymentReplica]: + """Get and remove all replicas of the given states. + + This removes the replicas from the container. Replicas are returned + in order of state as passed in. + + Args: + exclude_version: if specified, replicas of the + provided version will *not* be removed. + states: states to consider. If not specified, all replicas + are considered. + max_replicas: max number of replicas to return. If not + specified, will pop all replicas matching the criteria. + """ + if states is None: + states = ALL_REPLICA_STATES + + assert exclude_version is None or isinstance(exclude_version, DeploymentVersion) + assert isinstance(states, list) + + replicas = [] + for state in states: + popped = [] + remaining = [] + + for replica in self._replicas[state]: + if len(replicas) + len(popped) == max_replicas: + remaining.append(replica) + elif exclude_version is not None and replica.version == exclude_version: + remaining.append(replica) + else: + popped.append(replica) + + self._replicas[state] = remaining + replicas.extend(popped) + + return replicas + + def count( + self, + exclude_version: Optional[DeploymentVersion] = None, + version: Optional[DeploymentVersion] = None, + states: Optional[List[ReplicaState]] = None, + ): + """Get the total count of replicas of the given states. + + Args: + exclude_version: version to exclude. If not + specified, all versions are considered. + version: version to filter to. If not specified, + all versions are considered. + states: states to consider. If not specified, all replicas + are considered. + """ + if states is None: + states = ALL_REPLICA_STATES + assert isinstance(states, list) + assert exclude_version is None or isinstance(exclude_version, DeploymentVersion) + assert version is None or isinstance(version, DeploymentVersion) + if exclude_version is None and version is None: + return sum(len(self._replicas[state]) for state in states) + elif exclude_version is None and version is not None: + return sum( + len(list(filter(lambda r: r.version == version, self._replicas[state]))) + for state in states + ) + elif exclude_version is not None and version is None: + return sum( + len( + list( + filter( + lambda r: r.version != exclude_version, + self._replicas[state], + ) + ) + ) + for state in states + ) + else: + raise ValueError( + "Only one of `version` or `exclude_version` may be provided." + ) + + def __str__(self): + return str(self._replicas) + + def __repr__(self): + return repr(self._replicas) + + +class RankManager: + """Manages ranks for a single node.""" + + def __init__(self): + self._ranks: Dict[str, int] = {} + self._released_ranks: Set[int] = set() + self._next_rank: int = 0 + + def assign_rank(self, key: str) -> int: + if key in self._ranks: + raise RuntimeError(f"Rank for {key} already assigned: {self._ranks[key]}") + + if self._released_ranks: + # Reuse the smallest released rank + rank = min(self._released_ranks) + self._released_ranks.remove(rank) + else: + # Assign the next available rank + # This is the first time we're assigning a rank to this replica + rank = self._next_rank + self._next_rank += 1 + + self._ranks[key] = rank + return rank + + def release_rank(self, key: str) -> None: + if key not in self._ranks: + raise RuntimeError(f"Rank for {key} not assigned") + rank = self._ranks.pop(key) + # Add the released rank to the set of released ranks + # This rank can be reused for a new replica + self._released_ranks.add(rank) + + def recover_rank(self, key: str, rank: int) -> None: + if key in self._ranks: + raise RuntimeError(f"Rank for {key} already assigned: {self._ranks[key]}") + self._ranks[key] = rank + self._released_ranks.discard(rank) + if rank >= self._next_rank: + self._next_rank = rank + 1 + + def get_rank(self, key: str) -> int: + if key not in self._ranks: + raise RuntimeError(f"Rank for {key} not assigned") + return self._ranks[key] + + def has_rank(self, key: str) -> bool: + return key in self._ranks + + def get_ranks_mapping(self) -> Dict[str, int]: + return self._ranks.copy() + + def clear(self) -> None: + self._ranks.clear() + self._released_ranks.clear() + self._next_rank = 0 + + def check_rank_consistency_and_reassign_minimally( + self, + active_keys: List[str], + ) -> List[str]: + """Verify rank system invariants and reassign ranks when needed. + + This method ensures: + 1. All active keys have ranks + 2. No duplicate ranks exist + 3. Ranks are contiguous when at target count + + Args: + active_keys: List of currently active keys + + Returns: + List of keys that need to be reconfigured with new ranks + + Raises: + RuntimeError: If rank system invariants are violated and fail_on_error=True + """ + if not active_keys: + return [] + + active_keys_set = set(active_keys) + + # Check for stale ranks - this should never happen + stale_keys = set(self._ranks.keys()) - active_keys_set + if stale_keys: + logger.error( + f"Found stale ranks for keys: {stale_keys}. " + "This should never happen. Please report this as a bug." + ) + raise RuntimeError("Rank system is in an invalid state.") + + # Verify system invariants - all active keys must have ranks + unranked_keys = active_keys_set - set(self._ranks.keys()) + if unranked_keys: + logger.error( + f"Found active keys without ranks: {unranked_keys}. " + "This should never happen. Please report this as a bug." + ) + raise RuntimeError("Rank system is in an invalid state.") + + # Check for duplicate ranks - this should never happen + rank_counts = {} + for key, rank in self._ranks.copy().items(): + if key in active_keys_set: # Only check active keys + rank_counts[rank] = rank_counts.get(rank, 0) + 1 + if rank_counts[rank] > 1: + logger.error( + f"Found duplicate rank {rank} assigned to multiple keys. " + "This should never happen. Please report this as a bug." + ) + raise RuntimeError("Rank system is in an invalid state.") + + # Check if we need to reassign ranks for contiguity + # Only force contiguity when at target count (e.g., after autoscaling down) + current_ranks = sorted(self._ranks.values()) + expected_ranks = list(range(len(active_keys))) + + keys_needing_reconfiguration_from_reassignment = [] + + if current_ranks != expected_ranks: + logger.debug( + f"At target count but ranks are not contiguous. " + f"Current: {current_ranks}, Expected: {expected_ranks}. " + "Performing minimal reassignment." + ) + keys_needing_reconfiguration_from_reassignment = ( + self._perform_minimal_rank_reassignment(active_keys) + ) + + return keys_needing_reconfiguration_from_reassignment + + def _perform_minimal_rank_reassignment(self, active_keys: List[str]) -> List[str]: + """Perform minimal rank reassignment to achieve contiguity. + + This method reassigns ranks while minimizing the number of keys that need + to be reconfigured. It prioritizes keeping existing ranks when possible. + + Args: + active_keys: List of currently active keys + + Returns: + List of keys that need to be reconfigured with new ranks + """ + target_ranks_set = set(range(len(active_keys))) + + # Find which keys need new ranks + keys_needing_ranks = [] + keys_keeping_ranks = [] + + for key in active_keys: + current_rank = self.get_rank(key) + + if current_rank in target_ranks_set: + # This key can keep its rank + target_ranks_set.remove(current_rank) # O(1) operation + keys_keeping_ranks.append(key) + else: + # This key needs a new rank + keys_needing_ranks.append(key) + + # Convert remaining target ranks to sorted list for deterministic assignment + available_ranks = sorted(target_ranks_set) + + # Assign new ranks to keys that need them + for i, key in enumerate(keys_needing_ranks): + new_rank = available_ranks[i] # O(1) operation + + # Store the old rank before updating + old_rank = self._ranks[key] + + logger.debug(f"Reassigning key {key}: rank {old_rank} -> {new_rank}") + + # Update the rank mapping + self._ranks[key] = new_rank + # Remove the newly assigned rank from available ranks + self._released_ranks.discard(new_rank) + # Add the old rank back to available ranks for reuse + self._released_ranks.add(old_rank) + + # Log the reassignment summary + logger.debug( + f"Minimal reassignment complete: {len(keys_keeping_ranks)} keys kept ranks, " + f"{len(keys_needing_ranks)} keys reassigned" + ) + + return keys_needing_ranks + + +class DeploymentRankManager: + """Manages replica ranks for a deployment. + This class handles rank assignment, release, consistency checking, and reassignment. + It maintains the rank system invariants and provides a clean interface for rank operations. + + Maintains one level of rank tracking: + - Global rank: Replica-level rank across all nodes (0, 1, 2, ...) + """ + + def __init__(self, fail_on_rank_error: bool = True): + # Global rank manager (existing replica-level rank) + self._replica_rank_manager = RankManager() + self._fail_on_rank_error = fail_on_rank_error + + def _execute_with_error_handling(self, func, safe_default, *args, **kwargs): + if self._fail_on_rank_error: + # Let exceptions propagate + return func(*args, **kwargs) + else: + # Catch exceptions and return safe default + try: + return func(*args, **kwargs) + except Exception as e: + logger.error(f"Error executing function {func.__name__}: {e}") + return safe_default + + def assign_rank(self, replica_id: str) -> ReplicaRank: + """Assign a rank to a new replica. + + Args: + replica_id: The unique ID of the replica + + Returns: + ReplicaRank object with the assigned rank + + Raises: + RuntimeError: If the replica already has a rank assigned + """ + + def _assign_rank_impl(): + if self.has_replica_rank(replica_id): + raise RuntimeError( + f"Rank for {replica_id} already assigned: {self._replica_rank_manager.get_rank(replica_id)}" + ) + + # Assign global rank + rank = self._replica_rank_manager.assign_rank(replica_id) + + return ReplicaRank(rank=rank, node_rank=-1, local_rank=-1) + + return self._execute_with_error_handling( + _assign_rank_impl, ReplicaRank(rank=0, node_rank=-1, local_rank=-1) + ) + + def release_rank(self, replica_id: str) -> None: + """Release rank for a replica. + + Args: + replica_id: ID of the replica + + Raises: + RuntimeError: If replica doesn't have ranks + """ + + def _release_rank_impl(): + if not self.has_replica_rank(replica_id): + raise RuntimeError(f"Rank for {replica_id} not assigned") + + # Release global rank + self._replica_rank_manager.release_rank(replica_id) + + return self._execute_with_error_handling(_release_rank_impl, None) + + def recover_rank( + self, + replica_id: str, + rank: ReplicaRank, + ) -> None: + """Recover rank for a replica (e.g., after controller restart). + + Args: + replica_id: ID of the replica + rank: The rank to recover + + Raises: + RuntimeError: If replica already has ranks assigned + """ + + def _recover_rank_impl(): + if self.has_replica_rank(replica_id): + raise RuntimeError( + f"Rank for {replica_id} already assigned: {self._replica_rank_manager.get_rank(replica_id)}" + ) + + # Recover global rank + self._replica_rank_manager.recover_rank(replica_id, rank.rank) + + return self._execute_with_error_handling(_recover_rank_impl, None) + + def has_replica_rank(self, replica_id: str) -> bool: + """Check if replica has a rank assigned. + + Args: + replica_id: The unique ID of the replica + + Returns: + True if the replica has a rank assigned, False otherwise + + Raises: + RuntimeError: If the replica doesn't have ranks assigned + """ + return self._replica_rank_manager.has_rank(replica_id) + + def get_replica_rank(self, replica_id: str) -> ReplicaRank: + """Get the rank for a replica. + + Args: + replica_id: ID of the replica + + Returns: + ReplicaRank object + + Raises: + RuntimeError: If replica doesn't have ranks assigned + """ + + def _get_replica_rank_impl(): + if not self.has_replica_rank(replica_id): + raise RuntimeError(f"Rank for {replica_id} not assigned") + + global_rank = self._replica_rank_manager.get_rank(replica_id) + return ReplicaRank(rank=global_rank, node_rank=-1, local_rank=-1) + + return self._execute_with_error_handling( + _get_replica_rank_impl, ReplicaRank(rank=0, node_rank=-1, local_rank=-1) + ) + + def check_rank_consistency_and_reassign_minimally( + self, + active_replicas: List["DeploymentReplica"], + ) -> List["DeploymentReplica"]: + """Verify rank system invariants and reassign ranks when needed across all three levels. + + This method ensures: + 1. Global ranks are contiguous [0, N-1] for N replicas + + Args: + active_replicas: List of currently active replicas + + Returns: + List of replicas that need to be reconfigured with new ranks + """ + + def _check_rank_consistency_impl(): + if not active_replicas: + return [] + + # Extract replica IDs from replicas + active_replica_ids = [ + replica.replica_id.unique_id for replica in active_replicas + ] + + # Create a mapping from replica ID to replica object for quick lookup + replica_id_to_replica = { + replica.replica_id.unique_id: replica for replica in active_replicas + } + + # Track all replicas needing reconfiguration from any rank system + all_replica_ids_needing_reconfiguration = set() + + # STEP 1: Check global rank consistency + replica_ids_from_global = self._replica_rank_manager.check_rank_consistency_and_reassign_minimally( + active_replica_ids + ) + all_replica_ids_needing_reconfiguration.update(replica_ids_from_global) + + # Convert replica IDs back to replica objects + # Filter out stale replicas that are not in the active set + replicas_needing_reconfiguration = [ + replica_id_to_replica[replica_id] + for replica_id in all_replica_ids_needing_reconfiguration + if replica_id in replica_id_to_replica + ] + + return replicas_needing_reconfiguration + + return self._execute_with_error_handling(_check_rank_consistency_impl, []) + + def clear(self) -> None: + self._replica_rank_manager.clear() + + def get_replica_ranks_mapping(self) -> Dict[str, int]: + return self._replica_rank_manager.get_ranks_mapping() + + +class DeploymentState: + """Manages the target state and replicas for a single deployment.""" + + FORCE_STOP_UNHEALTHY_REPLICAS = RAY_SERVE_FORCE_STOP_UNHEALTHY_REPLICAS + MAX_CONSTRUCTOR_RETRY_COUNT_WARNING_LOGGED = False + + def __init__( + self, + id: DeploymentID, + long_poll_host: LongPollHost, + deployment_scheduler: DeploymentScheduler, + cluster_node_info_cache: ClusterNodeInfoCache, + autoscaling_state_manager: AutoscalingStateManager, + ): + self._id = id + self._long_poll_host: LongPollHost = long_poll_host + self._deployment_scheduler = deployment_scheduler + self._cluster_node_info_cache = cluster_node_info_cache + self._autoscaling_state_manager = autoscaling_state_manager + + # Each time we set a new deployment goal, we're trying to save new + # DeploymentInfo and bring current deployment to meet new status. + self._target_state: DeploymentTargetState = DeploymentTargetState.default() + + self._prev_startup_warning: float = time.time() + self._replica_constructor_error_msg: Optional[str] = None + # Counter for how many times replicas failed to start. This is reset to 0 when: + # (1) The deployment is deployed / re-deployed. + # (2) The deployment reaches the HEALTHY state. + self._replica_constructor_retry_counter: int = 0 + # Flag for whether any replicas of the target version has successfully started. + # This is reset to False when the deployment is re-deployed. + self._replica_has_started: bool = False + + self._replicas: ReplicaStateContainer = ReplicaStateContainer() + self._curr_status_info: DeploymentStatusInfo = DeploymentStatusInfo( + self._id.name, + DeploymentStatus.UPDATING, + DeploymentStatusTrigger.CONFIG_UPDATE_STARTED, + ) + + self._rank_manager = DeploymentRankManager( + fail_on_rank_error=RAY_SERVE_FAIL_ON_RANK_ERROR + ) + + self.replica_average_ongoing_requests: Dict[str, float] = {} + + self.health_check_gauge = metrics.Gauge( + "serve_deployment_replica_healthy", + description=( + "Tracks whether this deployment replica is healthy. 1 means " + "healthy, 0 means unhealthy." + ), + tag_keys=("deployment", "replica", "application"), + ) + + # Whether the request routing info have been updated since the last + # time we checked. + self._request_routing_info_updated = False + + self._last_broadcasted_running_replica_infos: List[RunningReplicaInfo] = [] + self._last_broadcasted_availability: bool = True + self._last_broadcasted_deployment_config = None + + self._docs_path: Optional[str] = None + self._route_patterns: Optional[List[str]] = None + + def should_autoscale(self) -> bool: + """ + Check if the deployment is under autoscaling + """ + return self._autoscaling_state_manager.should_autoscale_deployment(self._id) + + def get_checkpoint_data(self) -> DeploymentTargetState: + """ + Return deployment's target state submitted by user's deployment call. + Should be persisted and outlive current ray cluster. + """ + return self._target_state + + def recover_target_state_from_checkpoint( + self, target_state_checkpoint: DeploymentTargetState + ): + logger.info(f"Recovering target state for {self._id} from checkpoint.") + self._target_state = target_state_checkpoint + self._deployment_scheduler.on_deployment_deployed( + self._id, self._target_state.info.replica_config + ) + if self._target_state.info.deployment_config.autoscaling_config: + self._autoscaling_state_manager.register_deployment( + self._id, + self._target_state.info, + self._target_state.target_num_replicas, + ) + + def recover_current_state_from_replica_actor_names( + self, replica_actor_names: List[str] + ): + """Recover deployment state from live replica actors found in the cluster.""" + + assert self._target_state is not None, ( + "Target state should be recovered successfully first before " + "recovering current state from replica actor names." + ) + logger.info( + f"Recovering current state for {self._id} " + f"from {len(replica_actor_names)} live actors." + ) + # All current states use default value, only attach running replicas. + for replica_actor_name in replica_actor_names: + replica_id = ReplicaID.from_full_id_str(replica_actor_name) + new_deployment_replica = DeploymentReplica( + replica_id, + self._target_state.version, + ) + # If replica is no longer alive, simply don't add it to the + # deployment state manager to track. + if not new_deployment_replica.recover(): + logger.warning(f"{replica_id} died before controller could recover it.") + continue + + self._replicas.add(ReplicaState.RECOVERING, new_deployment_replica) + self._deployment_scheduler.on_replica_recovering(replica_id) + logger.debug(f"RECOVERING {replica_id}.") + + # TODO(jiaodong): this currently halts all traffic in the cluster + # briefly because we will broadcast a replica update with everything in + # RECOVERING. We should have a grace period where we recover the state + # of the replicas before doing this update. + + @property + def target_info(self) -> DeploymentInfo: + return self._target_state.info + + @property + def target_version(self) -> DeploymentVersion: + return self._target_state.version + + @property + def target_num_replicas(self) -> int: + return self._target_state.target_num_replicas + + @property + def curr_status_info(self) -> DeploymentStatusInfo: + return self._curr_status_info + + @property + def deployment_name(self) -> str: + return self._id.name + + @property + def app_name(self) -> str: + return self._id.app_name + + @property + def docs_path(self) -> Optional[str]: + return self._docs_path + + @property + def route_patterns(self) -> Optional[List[str]]: + return self._route_patterns + + @property + def _failed_to_start_threshold(self) -> int: + # Use global override if set, otherwise use deployment config + value = MAX_DEPLOYMENT_CONSTRUCTOR_RETRY_COUNT + if value is not None and not self.MAX_CONSTRUCTOR_RETRY_COUNT_WARNING_LOGGED: + logger.warning( + "MAX_DEPLOYMENT_CONSTRUCTOR_RETRY_COUNT is deprecated and will be removed in the future. " + "Please use 'max_constructor_retry_count' instead in configurations." + ) + self.MAX_CONSTRUCTOR_RETRY_COUNT_WARNING_LOGGED = True + base_retry_count = ( + value + if value is not None + else self._target_state.info.deployment_config.max_constructor_retry_count + ) + + return min( + base_retry_count, + self._target_state.target_num_replicas * MAX_PER_REPLICA_RETRY_COUNT, + ) + + def _replica_startup_failing(self) -> bool: + """Check whether replicas are currently failing and the number of + failures has exceeded a threshold. + """ + return ( + self._target_state.target_num_replicas > 0 + and self._replica_constructor_retry_counter + >= self._failed_to_start_threshold + ) + + def _terminally_failed(self) -> bool: + """Check whether the current version is terminally errored. + + The version is considered terminally errored if the number of + replica failures has exceeded a threshold, and there hasn't been + any replicas of the target version that has successfully started. + """ + return not self._replica_has_started and self._replica_startup_failing() + + def get_alive_replica_actor_ids(self) -> Set[str]: + return {replica.actor_id for replica in self._replicas.get()} + + def get_running_replica_ids(self) -> List[ReplicaID]: + return [ + replica.replica_id + for replica in self._replicas.get( + [ReplicaState.RUNNING, ReplicaState.PENDING_MIGRATION] + ) + ] + + def get_running_replica_infos(self) -> List[RunningReplicaInfo]: + return [ + replica.get_running_replica_info(self._cluster_node_info_cache) + for replica in self._replicas.get( + [ReplicaState.RUNNING, ReplicaState.PENDING_MIGRATION] + ) + ] + + def get_num_running_replicas(self, version: DeploymentVersion = None) -> int: + return self._replicas.count(states=[ReplicaState.RUNNING], version=version) + + def get_active_node_ids(self) -> Set[str]: + """Get the node ids of all running replicas in this deployment. + + This is used to determine which node has replicas. Only nodes with replicas and + head node should have active proxies. + """ + active_states = [ + ReplicaState.STARTING, + ReplicaState.UPDATING, + ReplicaState.RECOVERING, + ReplicaState.RUNNING, + # NOTE(zcin): We still want a proxy to run on a draining + # node before all the replicas are migrated. + ReplicaState.PENDING_MIGRATION, + ] + return { + replica.actor_node_id + for replica in self._replicas.get(active_states) + if replica.actor_node_id is not None + } + + def list_replica_details(self) -> List[ReplicaDetails]: + return [replica.actor_details for replica in self._replicas.get()] + + def broadcast_running_replicas_if_changed(self) -> None: + """Broadcasts the set of running replicas over long poll if it has changed. + + Keeps an in-memory record of the last set of running replicas that was broadcast + to determine if it has changed. + + The set will also be broadcast if any replicas have an updated set of + multiplexed model IDs. + """ + running_replica_infos = self.get_running_replica_infos() + is_available = not self._terminally_failed() + + running_replicas_changed = ( + set(self._last_broadcasted_running_replica_infos) + != set(running_replica_infos) + or self._request_routing_info_updated + ) + availability_changed = is_available != self._last_broadcasted_availability + if not running_replicas_changed and not availability_changed: + return + + deployment_metadata = DeploymentTargetInfo( + is_available=is_available, + running_replicas=running_replica_infos, + ) + self._long_poll_host.notify_changed( + { + ( + LongPollNamespace.DEPLOYMENT_TARGETS, + self._id, + ): deployment_metadata, + # NOTE(zcin): notify changed for Java routers. Since Java only + # supports 1.x API, there is no concept of applications in Java, + # so the key should remain a string describing the deployment + # name. If there are no Java routers, this is a no-op. + ( + LongPollNamespace.DEPLOYMENT_TARGETS, + self._id.name, + ): deployment_metadata, + } + ) + self._last_broadcasted_running_replica_infos = running_replica_infos + self._last_broadcasted_availability = is_available + self._request_routing_info_updated = False + + def broadcast_deployment_config_if_changed(self) -> None: + """Broadcasts the deployment config over long poll if it has changed. + + Keeps an in-memory record of the last config that was broadcast to determine + if it has changed. + """ + current_deployment_config = self._target_state.info.deployment_config + if self._last_broadcasted_deployment_config == current_deployment_config: + return + + self._long_poll_host.notify_changed( + {(LongPollNamespace.DEPLOYMENT_CONFIG, self._id): current_deployment_config} + ) + + self._last_broadcasted_deployment_config = current_deployment_config + + def _set_target_state_deleting(self) -> None: + """Set the target state for the deployment to be deleted.""" + target_state = DeploymentTargetState.create( + info=self._target_state.info, + target_num_replicas=0, + deleting=True, + ) + + self._target_state = target_state + self._curr_status_info = self._curr_status_info.handle_transition( + trigger=DeploymentStatusInternalTrigger.DELETE + ) + logger.info( + f"Deleting {self._id}", + extra={"log_to_stderr": False}, + ) + + def _set_target_state( + self, + target_info: DeploymentInfo, + target_num_replicas: int, + updated_via_api: bool = False, + ) -> None: + """Set the target state for the deployment to the provided info. + + Args: + target_info: The info with which to set the target state. + target_num_replicas: The number of replicas that this deployment + should attempt to run. + status_trigger: The driver that triggered this change of state. + updated_via_api: Whether the target state update was triggered via API. + """ + new_target_state = DeploymentTargetState.create( + target_info, target_num_replicas, deleting=False + ) + + if self._target_state.version == new_target_state.version: + # Record either num replica or autoscaling config lightweight update + if ( + self._target_state.version.deployment_config.autoscaling_config + != new_target_state.version.deployment_config.autoscaling_config + ): + ServeUsageTag.AUTOSCALING_CONFIG_LIGHTWEIGHT_UPDATED.record("True") + elif updated_via_api: + ServeUsageTag.NUM_REPLICAS_VIA_API_CALL_UPDATED.record("True") + elif ( + self._target_state.version.deployment_config.num_replicas + != new_target_state.version.deployment_config.num_replicas + ): + ServeUsageTag.NUM_REPLICAS_LIGHTWEIGHT_UPDATED.record("True") + + self._target_state = new_target_state + + def deploy(self, deployment_info: DeploymentInfo) -> bool: + """Deploy the deployment. + + If the deployment already exists with the same version, config, + target_capacity, and target_capacity_direction, + this method returns False. + + Returns: + bool: Whether the target state has changed. + """ + + curr_deployment_info = self._target_state.info + if curr_deployment_info is not None: + # Redeploying should not reset the deployment's start time. + if not self._target_state.deleting: + deployment_info.start_time_ms = curr_deployment_info.start_time_ms + + deployment_settings_changed = ( + self._target_state.deleting + or curr_deployment_info.deployment_config + != deployment_info.deployment_config + or curr_deployment_info.replica_config.ray_actor_options + != deployment_info.replica_config.ray_actor_options + or curr_deployment_info.route_prefix != deployment_info.route_prefix + or deployment_info.version is None + or curr_deployment_info.version != deployment_info.version + ) + target_capacity_changed = ( + curr_deployment_info.target_capacity != deployment_info.target_capacity + or curr_deployment_info.target_capacity_direction + != deployment_info.target_capacity_direction + ) + else: + deployment_settings_changed = True + target_capacity_changed = True + + # Exit early if the deployment info hasn't changed. Ensures this method + # is idempotent. + if not deployment_settings_changed and not target_capacity_changed: + return False + + if deployment_info.deployment_config.autoscaling_config: + target_num_replicas = self._autoscaling_state_manager.register_deployment( + self._id, deployment_info, self._target_state.target_num_replicas + ) + else: + self._autoscaling_state_manager.deregister_deployment(self._id) + target_num_replicas = get_capacity_adjusted_num_replicas( + deployment_info.deployment_config.num_replicas, + deployment_info.target_capacity, + ) + + old_target_state = self._target_state + self._set_target_state(deployment_info, target_num_replicas=target_num_replicas) + self._deployment_scheduler.on_deployment_deployed( + self._id, deployment_info.replica_config + ) + + # Determine if the updated target state simply scales the current state. + # Although the else branch handles the CONFIG_UPDATE, we also take this branch + # for a config update whose only effect is changing `num_replicas`. + # Treating it as a scaling event keeps the user-visible deployment status more + # consistent for observability. + if self._target_state.is_scaled_copy_of(old_target_state): + old_num = old_target_state.target_num_replicas + new_num = self._target_state.target_num_replicas + + if new_num > old_num: + self._curr_status_info = self._curr_status_info.handle_transition( + trigger=DeploymentStatusInternalTrigger.MANUALLY_INCREASE_NUM_REPLICAS, # noqa: E501 + message=f"Upscaling from {old_num} to {new_num} replicas.", + ) + elif new_num < old_num: + self._curr_status_info = self._curr_status_info.handle_transition( + trigger=DeploymentStatusInternalTrigger.MANUALLY_DECREASE_NUM_REPLICAS, # noqa: E501 + message=f"Downscaling from {old_num} to {new_num} replicas.", + ) + else: + # Otherwise, the deployment configuration has actually been updated. + self._curr_status_info = self._curr_status_info.handle_transition( + trigger=DeploymentStatusInternalTrigger.CONFIG_UPDATE + ) + + logger.info( + f"Deploying new version of {self._id} " + f"(initial target replicas: {target_num_replicas})." + ) + self._replica_constructor_retry_counter = 0 + self._replica_has_started = False + return True + + def autoscale(self, decision_num_replicas: int) -> bool: + """ + Apply the given scaling decision by updating the target replica count. + + Skips if deleting, if `decision_num_replicas` is None, or matches the + current target. Otherwise updates the state and logs an up/down scaling. + + Args: + decision_num_replicas: target replica count to apply. + + Returns: + bool: True if the target state was updated, False if no change occurred. + """ + + if self._target_state.deleting: + return False + + if decision_num_replicas == self._target_state.target_num_replicas: + return False + + new_info = copy(self._target_state.info) + new_info.version = self._target_state.version.code_version + + old_num = self._target_state.target_num_replicas + self._set_target_state(new_info, decision_num_replicas) + + # The deployment should only transition to UPSCALING/DOWNSCALING + # if it's within the autoscaling bounds + if not self._autoscaling_state_manager.is_within_bounds( + self._id, + self._replicas.count( + states=[ReplicaState.RUNNING], version=self._target_state.version + ), + ): + return True + + curr_stats_str = ( + f"Current ongoing requests: " + f"{self._autoscaling_state_manager.get_total_num_requests_for_deployment(self._id):.2f}, " + f"current running replicas: " + f"{self._replicas.count(states=[ReplicaState.RUNNING])}." + ) + new_num = self._target_state.target_num_replicas + if new_num > old_num: + logger.info( + f"Upscaling {self._id} from {old_num} to {new_num} replicas. " + f"{curr_stats_str}" + ) + self._curr_status_info = self._curr_status_info.handle_transition( + trigger=DeploymentStatusInternalTrigger.AUTOSCALE_UP, + message=f"Upscaling from {old_num} to {new_num} replicas.", + ) + elif new_num < old_num: + logger.info( + f"Downscaling {self._id} from {old_num} to {new_num} replicas. " + f"{curr_stats_str}" + ) + self._curr_status_info = self._curr_status_info.handle_transition( + trigger=DeploymentStatusInternalTrigger.AUTOSCALE_DOWN, + message=f"Downscaling from {old_num} to {new_num} replicas.", + ) + return True + + def delete(self) -> bool: + if not self._target_state.deleting: + self._set_target_state_deleting() + return True + + return False + + def set_target_num_replicas( + self, + target_num_replicas: int, + ) -> None: + """Set the target state for the deployment to the provided info.""" + self._set_target_state( + self._target_state.info, target_num_replicas, updated_via_api=True + ) + + def _stop_or_update_outdated_version_replicas(self, max_to_stop=math.inf) -> bool: + """Stop or update replicas with outdated versions. + + Stop replicas with versions that require the actor to be restarted, and + reconfigure replicas that require refreshing deployment config values. + + Args: + max_to_stop: max number of replicas to stop, by default, + it stops all replicas with an outdated version. + """ + replicas_to_update = self._replicas.pop( + exclude_version=self._target_state.version, + states=[ + ReplicaState.STARTING, + ReplicaState.PENDING_MIGRATION, + ReplicaState.RUNNING, + ], + ) + replicas_changed = False + code_version_changes = 0 + reconfigure_changes = 0 + for replica in replicas_to_update: + if (code_version_changes + reconfigure_changes) >= max_to_stop: + self._replicas.add(replica.actor_details.state, replica) + # If the new version requires the actors to be restarted, stop the replica. + # A new one with the correct version will be started later as part of the + # normal scale-up process. + elif replica.version.requires_actor_restart(self._target_state.version): + code_version_changes += 1 + # If the replica is still `STARTING`, we don't need to go through the + # graceful stop period. + graceful_stop = replica.actor_details.state == ReplicaState.RUNNING + self._stop_replica(replica, graceful_stop=graceful_stop) + replicas_changed = True + # Otherwise, only lightweight options in deployment config is a mismatch, so + # we update it dynamically without restarting the replica. + elif replica.actor_details.state == ReplicaState.RUNNING: + reconfigure_changes += 1 + if replica.version.requires_long_poll_broadcast( + self._target_state.version + ): + replicas_changed = True + # Get current rank for the replica + current_rank = self._rank_manager.get_replica_rank( + replica.replica_id.unique_id + ) + actor_updating = replica.reconfigure( + self._target_state.version, rank=current_rank.rank + ) + if actor_updating: + self._replicas.add(ReplicaState.UPDATING, replica) + else: + self._replicas.add(ReplicaState.RUNNING, replica) + # We don't allow going from STARTING, PENDING_MIGRATION to UPDATING. + else: + self._replicas.add(replica.actor_details.state, replica) + + if code_version_changes > 0: + logger.info( + f"Stopping {code_version_changes} replicas of {self._id} " + "with outdated versions." + ) + + if reconfigure_changes > 0: + logger.info( + f"Updating {reconfigure_changes} replicas of {self._id} " + "with outdated deployment configs." + ) + # Record user config lightweight update + ServeUsageTag.USER_CONFIG_LIGHTWEIGHT_UPDATED.record("True") + + return replicas_changed + + def _check_and_stop_outdated_version_replicas(self) -> bool: + """Stops replicas with outdated versions to implement rolling updates. + + This includes both explicit code version updates and changes to the + user_config. + + Returns whether any replicas were stopped. + """ + # Short circuit if target replicas is 0 (the deployment is being + # deleted) because this will be handled in the main loop. + if self._target_state.target_num_replicas == 0: + return False + + # We include STARTING and UPDATING replicas here + # because if there are replicas still pending startup, we may as well + # terminate them and start new version replicas instead. + old_running_replicas = self._replicas.count( + exclude_version=self._target_state.version, + states=[ + ReplicaState.STARTING, + ReplicaState.UPDATING, + ReplicaState.RUNNING, + ], + ) + old_stopping_replicas = self._replicas.count( + exclude_version=self._target_state.version, states=[ReplicaState.STOPPING] + ) + new_running_replicas = self._replicas.count( + version=self._target_state.version, states=[ReplicaState.RUNNING] + ) + + # If the deployment is currently scaling down, let the scale down + # complete before doing a rolling update. + if ( + self._target_state.target_num_replicas + < old_running_replicas + old_stopping_replicas + ): + return False + + # The number of replicas that are currently in transition between + # an old version and the new version. Note that we cannot directly + # count the number of stopping replicas because once replicas finish + # stopping, they are removed from the data structure. + pending_replicas = ( + self._target_state.target_num_replicas + - new_running_replicas + - old_running_replicas + ) + + # Maximum number of replicas that can be updating at any given time. + # There should never be more than rollout_size old replicas stopping + # or rollout_size new replicas starting. + rollout_size = max(int(0.2 * self._target_state.target_num_replicas), 1) + max_to_stop = max(rollout_size - pending_replicas, 0) + + return self._stop_or_update_outdated_version_replicas(max_to_stop) + + def scale_deployment_replicas( + self, + ) -> Tuple[List[ReplicaSchedulingRequest], DeploymentDownscaleRequest]: + """Scale the given deployment to the number of replicas.""" + + assert ( + self._target_state.target_num_replicas >= 0 + ), "Target number of replicas must be greater than or equal to 0." + + upscale = [] + downscale = None + + self._check_and_stop_outdated_version_replicas() + + current_replicas = self._replicas.count( + states=[ReplicaState.STARTING, ReplicaState.UPDATING, ReplicaState.RUNNING] + ) + recovering_replicas = self._replicas.count(states=[ReplicaState.RECOVERING]) + + delta_replicas = ( + self._target_state.target_num_replicas + - current_replicas + - recovering_replicas + ) + if delta_replicas == 0: + return (upscale, downscale) + + elif delta_replicas > 0: + to_add = delta_replicas + if to_add > 0 and not self._terminally_failed(): + logger.info(f"Adding {to_add} replica{'s' * (to_add>1)} to {self._id}.") + for _ in range(to_add): + replica_id = ReplicaID(get_random_string(), deployment_id=self._id) + + # Assign rank during replica creation (startup process) + assigned_rank = self._rank_manager.assign_rank(replica_id.unique_id) + + logger.debug( + f"Assigned rank {assigned_rank.rank} to new replica {replica_id.unique_id} during startup" + ) + new_deployment_replica = DeploymentReplica( + replica_id, + self._target_state.version, + ) + scheduling_request = new_deployment_replica.start( + self._target_state.info, rank=assigned_rank + ) + + upscale.append(scheduling_request) + + self._replicas.add(ReplicaState.STARTING, new_deployment_replica) + + elif delta_replicas < 0: + to_remove = -delta_replicas + removed_replicas = f"{to_remove} replica{'s' if to_remove > 1 else ''}" + logger.info(f"Removing {removed_replicas} from {self._id}.") + downscale = DeploymentDownscaleRequest( + deployment_id=self._id, num_to_stop=to_remove + ) + + return upscale, downscale + + def check_curr_status(self) -> Tuple[bool, bool]: + """Check the current deployment status. + + Checks the difference between the target vs. running replica count for + the target version. + + This will update the current deployment status depending on the state + of the replicas. + + Returns (deleted, any_replicas_recovering). + """ + # TODO(edoakes): we could make this more efficient in steady-state by + # having a "healthy" flag that gets flipped if an update or replica + # failure happens. + + target_version = self._target_state.version + + any_replicas_recovering = ( + self._replicas.count(states=[ReplicaState.RECOVERING]) > 0 + ) + all_running_replica_cnt = self._replicas.count(states=[ReplicaState.RUNNING]) + running_at_target_version_replica_cnt = self._replicas.count( + states=[ReplicaState.RUNNING], version=target_version + ) + + # Got to make a call to complete current deploy() goal after + # start failure threshold reached, while we might still have + # pending replicas in current goal. + if running_at_target_version_replica_cnt > 0: + # At least one RUNNING replica at target state, partial + # success; We can stop tracking constructor failures and + # leave it to the controller to fully scale to target + # number of replicas and only return as completed once + # reached target replica count + self._replica_has_started = True + elif self._replica_startup_failing(): + self._curr_status_info = self._curr_status_info.handle_transition( + trigger=DeploymentStatusInternalTrigger.REPLICA_STARTUP_FAILED, + message=( + "The deployment failed to start " + f"{self._replica_constructor_retry_counter} times " + "in a row. This may be due to a problem with its " + "constructor or initial health check failing. See " + "controller logs for details. Error:\n" + f"{self._replica_constructor_error_msg}" + ), + ) + return False, any_replicas_recovering + + # If we have pending ops, the current goal is *not* ready. + if ( + self._replicas.count( + states=[ + ReplicaState.STARTING, + ReplicaState.UPDATING, + ReplicaState.RECOVERING, + ReplicaState.STOPPING, + ] + ) + == 0 + ): + # Check for deleting and a non-zero number of deployments. + if self._target_state.deleting and all_running_replica_cnt == 0: + return True, any_replicas_recovering + + if ( + self._target_state.target_num_replicas + == running_at_target_version_replica_cnt + and running_at_target_version_replica_cnt == all_running_replica_cnt + ): + self._curr_status_info = self._curr_status_info.handle_transition( + trigger=DeploymentStatusInternalTrigger.HEALTHY + ) + self._replica_constructor_retry_counter = 0 + return False, any_replicas_recovering + + return False, any_replicas_recovering + + def _check_startup_replicas( + self, original_state: ReplicaState, stop_on_slow=False + ) -> List[Tuple[DeploymentReplica, ReplicaStartupStatus]]: + """ + Common helper function for startup actions tracking and status + transition: STARTING, UPDATING and RECOVERING. + + Args: + stop_on_slow: If we consider a replica failed upon observing it's + slow to reach running state. + """ + slow_replicas = [] + for replica in self._replicas.pop(states=[original_state]): + start_status, error_msg = replica.check_started() + if start_status == ReplicaStartupStatus.SUCCEEDED: + if original_state == ReplicaState.RECOVERING: + # If the previous state was RECOVERING, that mean the replica + # crashed and is now starting up again. We need to recover the rank + # from the replica actor. The invariant is that the rank is assigned + # during startup and before the replica is added to the replicas + # data structure with RUNNING state. + # Recover rank from the replica actor during controller restart + replica_id = replica.replica_id.unique_id + self._rank_manager.recover_rank(replica_id, replica.rank) + # This replica should be now be added to handle's replica + # set. + self._replicas.add(ReplicaState.RUNNING, replica) + self._deployment_scheduler.on_replica_running( + replica.replica_id, replica.actor_node_id + ) + + # if replica version is the same as the target version, + # we update the docs path and route patterns + if replica.version == self._target_state.version: + self._docs_path = replica.docs_path + self._route_patterns = replica.route_patterns + + # Log the startup latency. + e2e_replica_start_latency = time.time() - replica._start_time + replica_startup_message = ( + f"{replica.replica_id} started successfully " + f"on node '{replica.actor_node_id}' after " + f"{e2e_replica_start_latency:.1f}s (PID: {replica.actor_pid})." + ) + if replica.initialization_latency_s is not None: + # This condition should always be True. The initialization + # latency is only None before the replica has initialized. + replica_startup_message += ( + " Replica constructor, " + "reconfigure method, and initial health check took " + f"{replica.initialization_latency_s:.1f}s." + ) + logger.info(replica_startup_message, extra={"log_to_stderr": False}) + + elif start_status == ReplicaStartupStatus.FAILED: + # Replica reconfigure (deploy / upgrade) failed + self.record_replica_startup_failure(error_msg) + self._stop_replica(replica) + elif start_status in [ + ReplicaStartupStatus.PENDING_ALLOCATION, + ReplicaStartupStatus.PENDING_INITIALIZATION, + ]: + is_slow = time.time() - replica._start_time > SLOW_STARTUP_WARNING_S + if is_slow: + slow_replicas.append((replica, start_status)) + + # Does it make sense to stop replicas in PENDING_ALLOCATION + # state? + if is_slow and stop_on_slow: + self._stop_replica(replica, graceful_stop=False) + else: + self._replicas.add(original_state, replica) + + return slow_replicas + + def record_replica_startup_failure(self, error_msg: str): + """Record that a replica failed to start.""" + + # There is no need to record replica failures if the target is 0. + if self._target_state.target_num_replicas == 0: + return + + # Increase startup failure counter + self._replica_constructor_retry_counter += 1 + self._replica_constructor_error_msg = error_msg + + # Update the deployment message only if replicas are failing during + # the very first time the controller is trying to start replicas of + # this version. + retrying_msg = "" + if not self._replica_has_started: + remaining_retries = max( + self._failed_to_start_threshold + - self._replica_constructor_retry_counter, + 0, + ) + retrying_msg = f" {remaining_retries} more time(s)" + + message = ( + f"A replica failed to start with exception. Retrying{retrying_msg}. " + f"Error:\n{error_msg}" + ) + self._curr_status_info = self._curr_status_info.update_message(message) + + def stop_replicas(self, replicas_to_stop) -> None: + for replica in self._replicas.pop(): + if replica.replica_id in replicas_to_stop: + self._stop_replica(replica) + else: + self._replicas.add(replica.actor_details.state, replica) + + def _stop_replica(self, replica: DeploymentReplica, graceful_stop=True): + """Stop replica + 1. Stop the replica. + 2. Change the replica into stopping state. + 3. Set the health replica stats to 0. + """ + logger.debug(f"Adding STOPPING to replica: {replica.replica_id}.") + replica.stop(graceful=graceful_stop) + self._replicas.add(ReplicaState.STOPPING, replica) + self._deployment_scheduler.on_replica_stopping(replica.replica_id) + self.health_check_gauge.set( + 0, + tags={ + "deployment": self.deployment_name, + "replica": replica.replica_id.unique_id, + "application": self.app_name, + }, + ) + + def check_and_update_replicas(self): + """ + Check current state of all DeploymentReplica being tracked, and compare + with state container from previous update() cycle to see if any state + transition happened. + """ + + for replica in self._replicas.pop( + states=[ReplicaState.RUNNING, ReplicaState.PENDING_MIGRATION] + ): + if replica.check_health(): + self._replicas.add(replica.actor_details.state, replica) + self.health_check_gauge.set( + 1, + tags={ + "deployment": self.deployment_name, + "replica": replica.replica_id.unique_id, + "application": self.app_name, + }, + ) + routing_stats = replica.pull_routing_stats() + replica.record_routing_stats(routing_stats) + else: + logger.warning( + f"Replica {replica.replica_id} failed health check, stopping it." + ) + self.health_check_gauge.set( + 0, + tags={ + "deployment": self.deployment_name, + "replica": replica.replica_id.unique_id, + "application": self.app_name, + }, + ) + self._stop_replica( + replica, graceful_stop=not self.FORCE_STOP_UNHEALTHY_REPLICAS + ) + # If this is a replica of the target version, the deployment + # enters the "UNHEALTHY" status until the replica is + # recovered or a new deploy happens. + if replica.version == self._target_state.version: + self._curr_status_info = self._curr_status_info.handle_transition( + trigger=DeploymentStatusInternalTrigger.HEALTH_CHECK_FAILED, + message="A replica's health check failed. This " + "deployment will be UNHEALTHY until the replica " + "recovers or a new deploy happens.", + ) + + slow_start_replicas = [] + slow_start = self._check_startup_replicas(ReplicaState.STARTING) + slow_update = self._check_startup_replicas(ReplicaState.UPDATING) + slow_recover = self._check_startup_replicas( + ReplicaState.RECOVERING, stop_on_slow=True + ) + + slow_start_replicas = slow_start + slow_update + slow_recover + + if ( + len(slow_start_replicas) + and time.time() - self._prev_startup_warning > SLOW_STARTUP_WARNING_PERIOD_S + ): + pending_allocation = [] + pending_initialization = [] + + for replica, startup_status in slow_start_replicas: + if startup_status == ReplicaStartupStatus.PENDING_ALLOCATION: + pending_allocation.append(replica) + if startup_status == ReplicaStartupStatus.PENDING_INITIALIZATION: + pending_initialization.append(replica) + + if len(pending_allocation) > 0: + required, available = pending_allocation[0].resource_requirements() + message = ( + f"Deployment '{self.deployment_name}' in application " + f"'{self.app_name}' has {len(pending_allocation)} replicas that " + f"have taken more than {SLOW_STARTUP_WARNING_S}s to be scheduled. " + "This may be due to waiting for the cluster to auto-scale or for a " + "runtime environment to be installed. " + f"Resources required for each replica: {required}, " + f"total resources available: {available}. " + "Use `ray status` for more details." + ) + logger.warning(message) + if _SCALING_LOG_ENABLED: + print_verbose_scaling_log() + # If status is UNHEALTHY, leave the status and message as is. + # The issue that caused the deployment to be unhealthy should be + # prioritized over this resource availability issue. + if self._curr_status_info.status not in [ + DeploymentStatus.UNHEALTHY, + DeploymentStatus.DEPLOY_FAILED, + ]: + self._curr_status_info = self._curr_status_info.update_message( + message + ) + + if len(pending_initialization) > 0: + message = ( + f"Deployment '{self.deployment_name}' in application " + f"'{self.app_name}' has {len(pending_initialization)} replicas " + f"that have taken more than {SLOW_STARTUP_WARNING_S}s to " + "initialize.\n" + "This may be caused by a slow __init__ or reconfigure method." + ) + logger.warning(message) + # If status is UNHEALTHY, leave the status and message as is. + # The issue that caused the deployment to be unhealthy should be + # prioritized over this resource availability issue. + if self._curr_status_info.status not in [ + DeploymentStatus.UNHEALTHY, + DeploymentStatus.DEPLOY_FAILED, + ]: + self._curr_status_info = self._curr_status_info.update_message( + message + ) + + self._prev_startup_warning = time.time() + + for replica in self._replicas.pop(states=[ReplicaState.STOPPING]): + stopped = replica.check_stopped() + if not stopped: + self._replicas.add(ReplicaState.STOPPING, replica) + else: + logger.info(f"{replica.replica_id} is stopped.") + # Release rank only after replica is successfully stopped + # This ensures rank is available during draining/graceful shutdown + replica_id = replica.replica_id.unique_id + self._rank_manager.release_rank(replica_id) + logger.debug( + f"Released rank from replica {replica_id} in deployment {self._id}" + ) + self._autoscaling_state_manager.on_replica_stopped(replica.replica_id) + + # After replica state updates, check rank consistency and perform minimal reassignment if needed + # This ensures ranks are continuous after lifecycle events + # Only do consistency check when deployment is stable (not during active updates) + # maybe this constraint need to be relaxed in the future. The implication is that + # if we delay the rank reassignment, the rank system will be in an invalid state + # for a longer period of time. Abrar made this decision because he is not confident + # about how rollouts work in the deployment state machine. + active_replicas = self._replicas.get() + if ( + active_replicas + and self._curr_status_info.status == DeploymentStatus.HEALTHY + ): + replicas_to_reconfigure = ( + self._rank_manager.check_rank_consistency_and_reassign_minimally( + active_replicas, + ) + ) + + # Reconfigure replicas that had their ranks reassigned + self._reconfigure_replicas_with_new_ranks(replicas_to_reconfigure) + + def _reconfigure_replicas_with_new_ranks( + self, replicas_to_reconfigure: List["DeploymentReplica"] + ): + """Reconfigure replicas with their new ranks after reassignment. + This uses the reconfigure() mechanism to update replicas with their new ranks. + """ + if not replicas_to_reconfigure: + return + + logger.debug( + f"Reconfiguring {len(replicas_to_reconfigure)} replicas with rank changes in deployment {self._id}" + ) + + updated_count = 0 + for replica in replicas_to_reconfigure: + replica_id = replica.replica_id.unique_id + new_rank = self._rank_manager.get_replica_rank(replica_id) + + # Use reconfigure() to update rank + # World size is calculated automatically from deployment config + _ = replica.reconfigure( + self._target_state.version, + rank=new_rank, + ) + updated_count += 1 + + logger.debug( + f"Successfully reconfigured {updated_count} replicas with new ranks in deployment {self._id}" + ) + + def _get_replica_ranks_mapping(self) -> Dict[str, int]: + """Get the current mapping of replica IDs to ranks. + Returns: + Dictionary mapping replica_id to rank. + """ + return self._rank_manager.get_replica_ranks_mapping() + + def _choose_pending_migration_replicas_to_stop( + self, + replicas: List[DeploymentReplica], + deadlines: Dict[str, int], + min_replicas_to_stop: int, + ) -> Tuple[List[DeploymentReplica], List[DeploymentReplica]]: + """Returns a partition of replicas to stop and to keep. + + Args: + replicas: The current list of replicas pending migration. + deadlines: The current draining node deadlines. + min_replicas_to_stop: The minimum number of replicas to stop. + """ + to_stop = [] + remaining = [] + + # Stop replicas whose deadline is up + for replica in replicas: + assert replica.actor_node_id in deadlines + + curr_timestamp_ms = time.time() * 1000 + timeout_ms = replica._actor.graceful_shutdown_timeout_s * 1000 + if curr_timestamp_ms >= deadlines[replica.actor_node_id] - timeout_ms: + to_stop.append(replica) + else: + remaining.append(replica) + + # Stop excess PENDING_MIGRATION replicas when new "replacement" + # replicas have transitioned to RUNNING. The replicas with the + # earliest deadlines should be chosen greedily. + remaining.sort(key=lambda r: deadlines[r.actor_node_id]) + num_excess = min_replicas_to_stop - len(to_stop) + + if num_excess > 0: + to_stop.extend(remaining[:num_excess]) + remaining = remaining[num_excess:] + + return to_stop, remaining + + def migrate_replicas_on_draining_nodes(self, draining_nodes: Dict[str, int]): + # Move replicas back to running if they are no longer on a draining node. + # If this causes the number of replicas to exceed the target state, + # they will be scaled down because `scale_deployment_replicas` is called on + # each deployment after this + for replica in self._replicas.pop(states=[ReplicaState.PENDING_MIGRATION]): + if replica.actor_node_id not in draining_nodes: + self._replicas.add(ReplicaState.RUNNING, replica) + else: + self._replicas.add(ReplicaState.PENDING_MIGRATION, replica) + + # Migrate replicas on draining nodes + for replica in self._replicas.pop( + states=[ReplicaState.UPDATING, ReplicaState.RUNNING, ReplicaState.STARTING] + ): + if replica.actor_node_id in draining_nodes: + # For RUNNING replicas, migrate them safely by starting + # a replacement replica first. + if replica.actor_details.state == ReplicaState.RUNNING: + logger.info( + f"Migrating {replica.replica_id} from draining node " + f"'{replica.actor_node_id}'. A new replica will be created on " + "another node." + ) + self._replicas.add(ReplicaState.PENDING_MIGRATION, replica) + # For replicas that are STARTING or UPDATING, might as + # well terminate them immediately to allow replacement + # replicas to start. Otherwise we need to wait for them + # to transition to RUNNING before starting migration. + else: + self._stop_replica(replica, graceful_stop=True) + else: + self._replicas.add(replica.actor_details.state, replica) + + num_running = self._replicas.count(states=[ReplicaState.RUNNING]) + num_draining = self._replicas.count(states=[ReplicaState.PENDING_MIGRATION]) + num_pending_migration_replicas_to_stop = ( + num_running + num_draining - self._target_state.target_num_replicas + ) + + ( + replicas_to_stop, + replicas_to_keep, + ) = self._choose_pending_migration_replicas_to_stop( + self._replicas.pop(states=[ReplicaState.PENDING_MIGRATION]), + draining_nodes, + num_pending_migration_replicas_to_stop, + ) + for replica in replicas_to_stop: + logger.info( + f"Stopping {replica.replica_id} " + f"on draining node {replica.actor_node_id}." + ) + self._stop_replica(replica, graceful_stop=True) + + for replica in replicas_to_keep: + self._replicas.add(ReplicaState.PENDING_MIGRATION, replica) + + def record_request_routing_info(self, info: RequestRoutingInfo) -> None: + """Records the multiplexed model IDs of a replica. + + Args: + info: RequestRoutingInfo including deployment name, replica tag, + multiplex model ids, and routing stats. + """ + # Find the replica + for replica in self._replicas.get(): + if replica.replica_id == info.replica_id: + if info.multiplexed_model_ids is not None: + replica.record_multiplexed_model_ids(info.multiplexed_model_ids) + if info.routing_stats is not None: + replica.record_routing_stats(info.routing_stats) + self._request_routing_info_updated = True + return + + logger.warning(f"{info.replica_id} not found.") + + def _stop_one_running_replica_for_testing(self): + running_replicas = self._replicas.pop(states=[ReplicaState.RUNNING]) + replica_to_stop = running_replicas.pop() + replica_to_stop.stop(graceful=False) + self._replicas.add(ReplicaState.STOPPING, replica_to_stop) + for replica in running_replicas: + self._replicas.add(ReplicaState.RUNNING, replica) + + def is_ingress(self) -> bool: + return self._target_state.info.ingress + + def get_outbound_deployments(self) -> Optional[List[DeploymentID]]: + """Get the outbound deployments. + + Returns: + Sorted list of deployment IDs that this deployment calls. None if + outbound deployments are not yet polled. + """ + result: Set[DeploymentID] = set() + has_outbound_deployments = False + for replica in self._replicas.get([ReplicaState.RUNNING]): + if replica.version != self._target_state.version: + # Only consider replicas of the target version + continue + outbound_deployments = replica.get_outbound_deployments() + if outbound_deployments is not None: + result.update(outbound_deployments) + has_outbound_deployments = True + if not has_outbound_deployments: + return None + return sorted(result, key=lambda d: (d.name)) + + +class DeploymentStateManager: + """Manages all state for deployments in the system. + + This class is *not* thread safe, so any state-modifying methods should be + called with a lock held. + """ + + def __init__( + self, + kv_store: KVStoreBase, + long_poll_host: LongPollHost, + all_current_actor_names: List[str], + all_current_placement_group_names: List[str], + cluster_node_info_cache: ClusterNodeInfoCache, + autoscaling_state_manager: AutoscalingStateManager, + head_node_id_override: Optional[str] = None, + create_placement_group_fn_override: Optional[Callable] = None, + ): + self._kv_store = kv_store + self._long_poll_host = long_poll_host + self._cluster_node_info_cache = cluster_node_info_cache + self._deployment_scheduler = default_impl.create_deployment_scheduler( + cluster_node_info_cache, + head_node_id_override, + create_placement_group_fn_override, + ) + self._autoscaling_state_manager = autoscaling_state_manager + + self._shutting_down = False + + self._deployment_states: Dict[DeploymentID, DeploymentState] = {} + self._app_deployment_mapping: Dict[str, Set[str]] = defaultdict(set) + + self._recover_from_checkpoint( + all_current_actor_names, all_current_placement_group_names + ) + + def _create_deployment_state(self, deployment_id): + self._deployment_scheduler.on_deployment_created( + deployment_id, SpreadDeploymentSchedulingPolicy() + ) + + return DeploymentState( + deployment_id, + self._long_poll_host, + self._deployment_scheduler, + self._cluster_node_info_cache, + self._autoscaling_state_manager, + ) + + def _map_actor_names_to_deployment( + self, all_current_actor_names: List[str] + ) -> Dict[str, List[str]]: + """ + Given a list of all actor names queried from current ray cluster, + map them to corresponding deployments. + + Example: + Args: + [A#zxc123, B#xcv234, A#qwe234] + Returns: + { + A: [A#zxc123, A#qwe234] + B: [B#xcv234] + } + """ + all_replica_names = [ + actor_name + for actor_name in all_current_actor_names + if ReplicaID.is_full_id_str(actor_name) + ] + deployment_to_current_replicas = defaultdict(list) + if len(all_replica_names) > 0: + for replica_name in all_replica_names: + replica_id = ReplicaID.from_full_id_str(replica_name) + deployment_to_current_replicas[replica_id.deployment_id].append( + replica_name + ) + + return deployment_to_current_replicas + + def _detect_and_remove_leaked_placement_groups( + self, + all_current_actor_names: List[str], + all_current_placement_group_names: List[str], + ): + """Detect and remove any placement groups not associated with a replica. + + This can happen under certain rare circumstances: + - The controller creates a placement group then crashes before creating + the associated replica actor. + - While the controller is down, a replica actor crashes but its placement + group still exists. + + In both of these (or any other unknown cases), we simply need to remove the + leaked placement groups. + """ + leaked_pg_names = [] + for pg_name in all_current_placement_group_names: + if ( + ReplicaID.is_full_id_str(pg_name) + and pg_name not in all_current_actor_names + ): + leaked_pg_names.append(pg_name) + + if len(leaked_pg_names) > 0: + logger.warning( + f"Detected leaked placement groups: {leaked_pg_names}. " + "The placement groups will be removed. This can happen in rare " + "circumstances when the controller crashes and should not cause any " + "issues. If this happens repeatedly, please file an issue on GitHub." + ) + + for leaked_pg_name in leaked_pg_names: + try: + pg = ray.util.get_placement_group(leaked_pg_name) + ray.util.remove_placement_group(pg) + except Exception: + logger.exception( + f"Failed to remove leaked placement group {leaked_pg_name}." + ) + + def _recover_from_checkpoint( + self, + all_current_actor_names: List[str], + all_current_placement_group_names: List[str], + ): + """ + Recover from checkpoint upon controller failure with all actor names + found in current cluster. + + Each deployment resumes target state from checkpoint if available. + + For current state it will prioritize reconstructing from current + actor names found that matches deployment tag if applicable. + """ + self._detect_and_remove_leaked_placement_groups( + all_current_actor_names, + all_current_placement_group_names, + ) + + deployment_to_current_replicas = self._map_actor_names_to_deployment( + all_current_actor_names + ) + checkpoint = self._kv_store.get(CHECKPOINT_KEY) + if checkpoint is not None: + deployment_state_info = cloudpickle.loads(checkpoint) + + for deployment_id, checkpoint_data in deployment_state_info.items(): + deployment_state = self._create_deployment_state(deployment_id) + deployment_state.recover_target_state_from_checkpoint(checkpoint_data) + if len(deployment_to_current_replicas[deployment_id]) > 0: + deployment_state.recover_current_state_from_replica_actor_names( # noqa: E501 + deployment_to_current_replicas[deployment_id] + ) + self._deployment_states[deployment_id] = deployment_state + self._app_deployment_mapping[deployment_id.app_name].add( + deployment_id.name + ) + + def shutdown(self): + """ + Shutdown all running replicas by notifying the controller, and leave + it to the controller event loop to take actions afterwards. + + Once shutdown signal is received, it will also prevent any new + deployments or replicas from being created. + + One can send multiple shutdown signals but won't effectively make any + difference compare to calling it once. + """ + self._shutting_down = True + + for deployment_state in self._deployment_states.values(): + deployment_state.delete() + + # TODO(jiaodong): This might not be 100% safe since we deleted + # everything without ensuring all shutdown goals are completed + # yet. Need to address in follow-up PRs. + self._kv_store.delete(CHECKPOINT_KEY) + + # TODO(jiaodong): Need to add some logic to prevent new replicas + # from being created once shutdown signal is sent. + + def is_ready_for_shutdown(self) -> bool: + """Return whether all deployments are shutdown. + + Check there are no deployment states and no checkpoints. + """ + return ( + self._shutting_down + and len(self._deployment_states) == 0 + and self._kv_store.get(CHECKPOINT_KEY) is None + ) + + def save_checkpoint(self) -> None: + """Write a checkpoint of all deployment states.""" + if self._shutting_down: + # Once we're told to shut down, stop writing checkpoints. + # Calling .shutdown() deletes any existing checkpoint. + return + + deployment_state_info = { + deployment_id: deployment_state.get_checkpoint_data() + for deployment_id, deployment_state in self._deployment_states.items() + } + + self._kv_store.put( + CHECKPOINT_KEY, + cloudpickle.dumps(deployment_state_info), + ) + + def get_running_replica_infos( + self, + ) -> Dict[DeploymentID, List[RunningReplicaInfo]]: + return { + id: deployment_state.get_running_replica_infos() + for id, deployment_state in self._deployment_states.items() + } + + def get_deployment_infos(self) -> Dict[DeploymentID, DeploymentInfo]: + infos: Dict[DeploymentID, DeploymentInfo] = {} + for deployment_id, deployment_state in self._deployment_states.items(): + infos[deployment_id] = deployment_state.target_info + + return infos + + def get_deployment(self, deployment_id: DeploymentID) -> Optional[DeploymentInfo]: + if deployment_id in self._deployment_states: + return self._deployment_states[deployment_id].target_info + else: + return None + + def get_deployment_docs_path(self, deployment_id: DeploymentID) -> Optional[str]: + if deployment_id in self._deployment_states: + return self._deployment_states[deployment_id].docs_path + + def get_deployment_route_patterns( + self, deployment_id: DeploymentID + ) -> Optional[List[str]]: + """Get route patterns for a deployment if available.""" + if deployment_id in self._deployment_states: + return self._deployment_states[deployment_id].route_patterns + return None + + def get_deployment_target_num_replicas( + self, deployment_id: DeploymentID + ) -> Optional[int]: + if deployment_id not in self._deployment_states: + return None + return self._deployment_states[deployment_id].target_num_replicas + + def get_deployment_details(self, id: DeploymentID) -> Optional[DeploymentDetails]: + """Gets detailed info on a deployment. + + Returns: + DeploymentDetails: if the deployment is live. + None: if the deployment is deleted. + """ + statuses = self.get_deployment_statuses([id]) + if len(statuses) == 0: + return None + else: + status_info = statuses[0] + deployment_state = self._deployment_states[id] + return DeploymentDetails( + name=id.name, + status=status_info.status, + status_trigger=status_info.status_trigger, + message=status_info.message, + deployment_config=_deployment_info_to_schema( + id.name, self.get_deployment(id) + ), + target_num_replicas=deployment_state._target_state.target_num_replicas, + required_resources=deployment_state.target_info.replica_config.resource_dict, + replicas=deployment_state.list_replica_details(), + ) + + def get_deployment_statuses( + self, ids: Optional[List[DeploymentID]] = None + ) -> List[DeploymentStatusInfo]: + """ + Return the statuses of the deployments with the given `ids`. + If `ids` is `None`, returns the status of all deployments. + """ + if ids is None: + # fast path for returning all deployments, + # avoids checking `if ids is None` in a loop + return [ + state.curr_status_info for state in self._deployment_states.values() + ] + else: + statuses = [] + for id in ids: + state = self._deployment_states.get(id) + if state is not None: + statuses.append(state.curr_status_info) + return statuses + + def get_alive_replica_actor_ids(self) -> Set[str]: + alive_replica_actor_ids = set() + for ds in self._deployment_states.values(): + alive_replica_actor_ids |= ds.get_alive_replica_actor_ids() + + return alive_replica_actor_ids + + def deploy( + self, + deployment_id: DeploymentID, + deployment_info: DeploymentInfo, + ) -> bool: + """Deploy the deployment. + + If the deployment already exists with the same version and config, + this is a no-op and returns False. + + Returns: + bool: Whether the target state has changed. + """ + if deployment_id not in self._deployment_states: + self._deployment_states[deployment_id] = self._create_deployment_state( + deployment_id + ) + self._app_deployment_mapping[deployment_id.app_name].add(deployment_id.name) + self._record_deployment_usage() + + return self._deployment_states[deployment_id].deploy(deployment_info) + + def get_deployments_in_application(self, app_name: str) -> List[str]: + """Return list of deployment names in application.""" + return list(self._app_deployment_mapping[app_name]) + + def delete_deployment(self, id: DeploymentID): + # This method must be idempotent. We should validate that the + # specified deployment exists on the client. + if id in self._deployment_states: + return self._deployment_states[id].delete() + + return False + + def _validate_deployment_state_for_num_replica_update( + self, deployment_id: DeploymentID + ): + """Validate the state of a deployment for num replica update.""" + statuses = self.get_deployment_statuses([deployment_id]) + + if statuses is None or len(statuses) == 0: + raise ValueError(f"Deployment {deployment_id} not found") + elif statuses[0].status_trigger == DeploymentStatusTrigger.DELETING: + raise DeploymentIsBeingDeletedError( + f"Deployment {deployment_id} is being deleted. Scaling operations are not allowed." + ) + + def set_target_num_replicas( + self, deployment_id: DeploymentID, target_num_replicas: int + ): + """Set target number of replicas for a deployment.""" + self._validate_deployment_state_for_num_replica_update(deployment_id) + + deployment_state = self._deployment_states[deployment_id] + if target_num_replicas != deployment_state.target_num_replicas: + logger.info( + f"Target number of replicas changed from {deployment_state.target_num_replicas} to {target_num_replicas} for deployment {deployment_id}" + ) + deployment_state.set_target_num_replicas(target_num_replicas) + self.save_checkpoint() + else: + logger.info( + f"Skipping updating target number of replicas as it did not change for deployment {deployment_id}" + ) + + def update(self) -> bool: + """Updates the state of all deployments to match their goal state. + + Returns True if any of the deployments have replicas in the RECOVERING state. + """ + + deleted_ids = [] + any_recovering = False + upscales: Dict[DeploymentID, List[ReplicaSchedulingRequest]] = {} + downscales: Dict[DeploymentID, DeploymentDownscaleRequest] = {} + target_state_changed = False + + # STEP 1: Update current state + for deployment_state in self._deployment_states.values(): + deployment_state.check_and_update_replicas() + + # STEP 2: Check current status + for deployment_state in self._deployment_states.values(): + deployment_state.check_curr_status() + + # STEP 3: Drain nodes + draining_nodes = self._cluster_node_info_cache.get_draining_nodes() + allow_new_compaction = len(draining_nodes) == 0 and all( + ds.curr_status_info.status == DeploymentStatus.HEALTHY + # TODO(zcin): Make sure that status should never be healthy if + # the number of running replicas at target version is not at + # target number, so we can remove this defensive check. + and ds.get_num_running_replicas(ds.target_version) == ds.target_num_replicas + # To be extra conservative, only actively compact if there + # are no non-running replicas + and len(ds._replicas.get()) == ds.target_num_replicas + for ds in self._deployment_states.values() + ) + if RAY_SERVE_USE_COMPACT_SCHEDULING_STRATEGY: + # Tuple of target node to compact, and its draining deadline + node_info: Optional[ + Tuple[str, float] + ] = self._deployment_scheduler.get_node_to_compact( + allow_new_compaction=allow_new_compaction + ) + if node_info: + target_node_id, deadline = node_info + draining_nodes = {target_node_id: deadline} + + for deployment_id, deployment_state in self._deployment_states.items(): + deployment_state.migrate_replicas_on_draining_nodes(draining_nodes) + + # STEP 4: Scale replicas + for deployment_id, deployment_state in self._deployment_states.items(): + upscale, downscale = deployment_state.scale_deployment_replicas() + + if upscale: + upscales[deployment_id] = upscale + if downscale: + downscales[deployment_id] = downscale + + # STEP 5: Update status + for deployment_id, deployment_state in self._deployment_states.items(): + deleted, any_replicas_recovering = deployment_state.check_curr_status() + + if deleted: + deleted_ids.append(deployment_id) + any_recovering |= any_replicas_recovering + + # STEP 6: Schedule all STARTING replicas and stop all STOPPING replicas + deployment_to_replicas_to_stop = self._deployment_scheduler.schedule( + upscales, downscales + ) + for deployment_id, replicas_to_stop in deployment_to_replicas_to_stop.items(): + self._deployment_states[deployment_id].stop_replicas(replicas_to_stop) + for deployment_id, scheduling_requests in upscales.items(): + self._handle_scheduling_request_failures(deployment_id, scheduling_requests) + + # STEP 7: Broadcast long poll information + for deployment_id, deployment_state in self._deployment_states.items(): + deployment_state.broadcast_running_replicas_if_changed() + deployment_state.broadcast_deployment_config_if_changed() + if deployment_state.should_autoscale(): + self._autoscaling_state_manager.update_running_replica_ids( + deployment_id=deployment_id, + running_replicas=deployment_state.get_running_replica_ids(), + ) + + # STEP 8: Cleanup + for deployment_id in deleted_ids: + self._deployment_scheduler.on_deployment_deleted(deployment_id) + self._autoscaling_state_manager.deregister_deployment(deployment_id) + del self._deployment_states[deployment_id] + if ( + deployment_id.app_name in self._app_deployment_mapping + and deployment_id.name + in self._app_deployment_mapping[deployment_id.app_name] + ): + self._app_deployment_mapping[deployment_id.app_name].remove( + deployment_id.name + ) + # Clean up the app_name entry if no deployments are left + if not self._app_deployment_mapping[deployment_id.app_name]: + del self._app_deployment_mapping[deployment_id.app_name] + + if len(deleted_ids): + self._record_deployment_usage() + + if target_state_changed: + self.save_checkpoint() + + return any_recovering + + def autoscale(self, deployment_id: DeploymentID, target_num_replicas: int) -> bool: + """Autoscale the deployment to the target number of replicas. + + Args: + deployment_id: The deployment ID. + target_num_replicas: The target number of replicas. + + Returns: + True if the deployment was autoscaled, False otherwise. + """ + if deployment_id not in self._deployment_states: + return False + + return self._deployment_states[deployment_id].autoscale(target_num_replicas) + + def _handle_scheduling_request_failures( + self, + deployment_id: DeploymentID, + scheduling_requests: List[ReplicaSchedulingRequest], + ): + """Updates internal datastructures when replicas fail to be scheduled.""" + failed_replicas: List[ReplicaID] = [] + for scheduling_request in scheduling_requests: + if ( + scheduling_request.status + == ReplicaSchedulingRequestStatus.PLACEMENT_GROUP_CREATION_FAILED + ): + failed_replicas.append(scheduling_request.replica_id) + self._deployment_states[deployment_id].record_replica_startup_failure( + "Replica scheduling failed. Failed to create a placement " + f"group for replica {scheduling_request.replica_id}. " + "See Serve controller logs for more details." + ) + elif ( + scheduling_request.status + == ReplicaSchedulingRequestStatus.ACTOR_CREATION_FAILED + ): + failed_replicas.append(scheduling_request.replica_id) + self._deployment_states[deployment_id].record_replica_startup_failure( + "Replica scheduling failed. Failed to create an actor " + f"for replica {scheduling_request.replica_id}. " + "See Serve controller logs for more details." + ) + if failed_replicas: + self._deployment_states[deployment_id].stop_replicas(failed_replicas) + + def _record_deployment_usage(self): + ServeUsageTag.NUM_DEPLOYMENTS.record(str(len(self._deployment_states))) + + num_gpu_deployments = 0 + for deployment_state in self._deployment_states.values(): + if ( + deployment_state.target_info is not None + and deployment_state.target_info.replica_config is not None + and deployment_state.target_info.replica_config.ray_actor_options + is not None + and ( + deployment_state.target_info.replica_config.ray_actor_options.get( + "num_gpus", 0 + ) + > 0 + ) + ): + num_gpu_deployments += 1 + ServeUsageTag.NUM_GPU_DEPLOYMENTS.record(str(num_gpu_deployments)) + + def record_request_routing_info(self, info: RequestRoutingInfo) -> None: + """ + Record request routing information for a replica. + + Args: + info: Request routing info including deployment name, replica tag, + multiplex model ids, and routing stats. + """ + deployment_id = info.replica_id.deployment_id + if deployment_id not in self._deployment_states: + app_msg = f" in application '{deployment_id.app_name}'" + logger.error( + f"Deployment '{deployment_id.name}'{app_msg} not found in state " + "manager." + ) + return + self._deployment_states[deployment_id].record_request_routing_info(info) + + def get_active_node_ids(self) -> Set[str]: + """Return set of node ids with running replicas of any deployment. + + This is used to determine which node has replicas. Only nodes with replicas and + head node should have active proxies. + """ + node_ids = set() + for deployment_state in self._deployment_states.values(): + node_ids.update(deployment_state.get_active_node_ids()) + return node_ids + + def get_ingress_replicas_info(self) -> List[Tuple[str, str, int, int]]: + """Get all ingress replicas info for all deployments.""" + ingress_replicas_list = [ + deployment_state._replicas.get() + for deployment_state in self._deployment_states.values() + if deployment_state.is_ingress() + ] + + ingress_replicas_info = [] + for replicas in ingress_replicas_list: + for replica in replicas: + ingress_replicas_info.append( + ( + replica.actor_node_id, + replica.replica_id.unique_id, + replica.actor_http_port, + replica.actor_grpc_port, + ) + ) + return ingress_replicas_info + + def _get_replica_ranks_mapping(self, deployment_id: DeploymentID) -> Dict[str, int]: + """Get the current rank mapping for all replicas in a deployment. + Args: + deployment_id: The deployment ID to get ranks for. + Returns: + Dictionary mapping replica_id to rank. + """ + deployment_state = self._deployment_states.get(deployment_id) + if deployment_state is None: + return {} + + return deployment_state._get_replica_ranks_mapping() + + def get_deployment_outbound_deployments( + self, deployment_id: DeploymentID + ) -> Optional[List[DeploymentID]]: + """Get the cached outbound deployments for a specific deployment. + + Args: + deployment_id: The deployment ID to get outbound deployments for. + + Returns: + List of deployment IDs that this deployment calls, or None if + the deployment doesn't exist or hasn't been polled yet. + """ + deployment_state = self._deployment_states.get(deployment_id) + if deployment_state is None: + return None + + return deployment_state.get_outbound_deployments() diff --git a/lib/python3.12/site-packages/ray/serve/_private/endpoint_state.py b/lib/python3.12/site-packages/ray/serve/_private/endpoint_state.py new file mode 100644 index 0000000000000000000000000000000000000000..fd2074fd66693d750bf64eb49dbe7e84450bed0d --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/_private/endpoint_state.py @@ -0,0 +1,109 @@ +import logging +from typing import Any, Dict, Optional + +from ray import cloudpickle +from ray.serve._private.common import DeploymentID, EndpointInfo +from ray.serve._private.constants import SERVE_LOGGER_NAME +from ray.serve._private.long_poll import LongPollHost, LongPollNamespace +from ray.serve._private.storage.kv_store import KVStoreBase + +CHECKPOINT_KEY = "serve-endpoint-state-checkpoint" + +logger = logging.getLogger(SERVE_LOGGER_NAME) + + +class EndpointState: + """Manages all state for endpoints in the system. + + This class is *not* thread safe, so any state-modifying methods should be + called with a lock held. + """ + + def __init__(self, kv_store: KVStoreBase, long_poll_host: LongPollHost): + self._kv_store = kv_store + self._long_poll_host = long_poll_host + self._endpoints: Dict[DeploymentID, EndpointInfo] = dict() + + checkpoint = self._kv_store.get(CHECKPOINT_KEY) + if checkpoint is not None: + self._endpoints = cloudpickle.loads(checkpoint) + + self._notify_route_table_changed() + + def shutdown(self): + self._kv_store.delete(CHECKPOINT_KEY) + + def is_ready_for_shutdown(self) -> bool: + """Returns whether the endpoint checkpoint has been deleted. + + Get the endpoint checkpoint from the kv store. If it is None, then it has been + deleted. + """ + return self._kv_store.get(CHECKPOINT_KEY) is None + + def _checkpoint(self): + self._kv_store.put(CHECKPOINT_KEY, cloudpickle.dumps(self._endpoints)) + + def _notify_route_table_changed(self): + self._long_poll_host.notify_changed( + {LongPollNamespace.ROUTE_TABLE: self._endpoints} + ) + + def _get_endpoint_for_route(self, route: str) -> Optional[DeploymentID]: + for endpoint, info in self._endpoints.items(): + if info.route == route: + return endpoint + + return None + + def update_endpoint( + self, endpoint: DeploymentID, endpoint_info: EndpointInfo + ) -> None: + """Create or update the given endpoint. + + This method is idempotent - if the endpoint already exists it will be + updated to match the given parameters. Calling this twice with the same + arguments is a no-op. + """ + + if self._endpoints.get(endpoint) == endpoint_info: + return + + existing_route_endpoint = self._get_endpoint_for_route(endpoint_info.route) + if existing_route_endpoint is not None and existing_route_endpoint != endpoint: + logger.debug( + f'route_prefix "{endpoint_info.route}" is currently ' + f'registered to deployment "{existing_route_endpoint.name}". ' + f'Re-registering route_prefix "{endpoint_info.route}" to ' + f'deployment "{endpoint.name}".' + ) + del self._endpoints[existing_route_endpoint] + + self._endpoints[endpoint] = endpoint_info + + self._checkpoint() + self._notify_route_table_changed() + + def get_endpoint_route(self, endpoint: DeploymentID) -> Optional[str]: + if endpoint in self._endpoints: + return self._endpoints[endpoint].route + return None + + def get_endpoints(self) -> Dict[DeploymentID, Dict[str, Any]]: + endpoints = {} + for endpoint, info in self._endpoints.items(): + endpoints[endpoint] = { + "route": info.route, + } + return endpoints + + def delete_endpoint(self, endpoint: DeploymentID) -> None: + # This method must be idempotent. We should validate that the + # specified endpoint exists on the client. + if endpoint not in self._endpoints: + return + + del self._endpoints[endpoint] + + self._checkpoint() + self._notify_route_table_changed() diff --git a/lib/python3.12/site-packages/ray/serve/_private/exceptions.py b/lib/python3.12/site-packages/ray/serve/_private/exceptions.py new file mode 100644 index 0000000000000000000000000000000000000000..4859c9d464d5e2378299990c2e62994735ca8c24 --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/_private/exceptions.py @@ -0,0 +1,4 @@ +class DeploymentIsBeingDeletedError(Exception): + """Raised when an operation is attempted on a deployment that is being deleted.""" + + pass diff --git a/lib/python3.12/site-packages/ray/serve/_private/grpc_util.py b/lib/python3.12/site-packages/ray/serve/_private/grpc_util.py new file mode 100644 index 0000000000000000000000000000000000000000..147f68bff9bd6ba81ab1068a25699d72b60529f0 --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/_private/grpc_util.py @@ -0,0 +1,179 @@ +import asyncio +import logging +from copy import deepcopy +from typing import Callable, List, Optional, Sequence, Tuple +from unittest.mock import Mock + +import grpc +from grpc.aio._server import Server + +from ray.exceptions import RayActorError, RayTaskError +from ray.serve._private.constants import ( + DEFAULT_GRPC_SERVER_OPTIONS, + RAY_SERVE_REQUEST_PROCESSING_TIMEOUT_S, + SERVE_LOGGER_NAME, +) +from ray.serve._private.proxy_request_response import ResponseStatus +from ray.serve.config import gRPCOptions +from ray.serve.exceptions import BackPressureError, DeploymentUnavailableError +from ray.serve.generated.serve_pb2_grpc import add_RayServeAPIServiceServicer_to_server + +logger = logging.getLogger(SERVE_LOGGER_NAME) + + +class gRPCGenericServer(Server): + """Custom gRPC server that will override all service method handlers. + + Original implementation see: https://github.com/grpc/grpc/blob/ + 60c1701f87cacf359aa1ad785728549eeef1a4b0/src/python/grpcio/grpc/aio/_server.py + """ + + def __init__( + self, + service_handler_factory: Callable, + *, + extra_options: Optional[List[Tuple[str, str]]] = None, + ): + super().__init__( + thread_pool=None, + generic_handlers=(), + interceptors=(), + maximum_concurrent_rpcs=None, + compression=None, + options=DEFAULT_GRPC_SERVER_OPTIONS + (extra_options or []), + ) + self.generic_rpc_handlers = [] + self.service_handler_factory = service_handler_factory + + def add_generic_rpc_handlers( + self, generic_rpc_handlers: Sequence[grpc.GenericRpcHandler] + ): + """Override generic_rpc_handlers before adding to the gRPC server. + + This function will override all user defined handlers to have + 1. None `response_serializer` so the server can pass back the + raw protobuf bytes to the user. + 2. `unary_unary` is always calling the unary function generated via + `self.service_handler_factory` + 3. `unary_stream` is always calling the streaming function generated via + `self.service_handler_factory` + """ + serve_rpc_handlers = {} + rpc_handler = generic_rpc_handlers[0] + for service_method, method_handler in rpc_handler._method_handlers.items(): + serve_method_handler = method_handler._replace( + response_serializer=None, + unary_unary=self.service_handler_factory( + service_method=service_method, + stream=False, + ), + unary_stream=self.service_handler_factory( + service_method=service_method, + stream=True, + ), + ) + serve_rpc_handlers[service_method] = serve_method_handler + generic_rpc_handlers[0]._method_handlers = serve_rpc_handlers + self.generic_rpc_handlers.append(generic_rpc_handlers) + super().add_generic_rpc_handlers(generic_rpc_handlers) + + +async def start_grpc_server( + service_handler_factory: Callable, + grpc_options: gRPCOptions, + *, + event_loop: asyncio.AbstractEventLoop, + enable_so_reuseport: bool = False, +) -> asyncio.Task: + """Start a gRPC server that handles requests with the service handler factory. + + Returns a task that blocks until the server exits (e.g., due to error). + """ + from ray.serve._private.default_impl import add_grpc_address + + server = gRPCGenericServer( + service_handler_factory, + extra_options=[("grpc.so_reuseport", str(int(enable_so_reuseport)))], + ) + add_grpc_address(server, f"[::]:{grpc_options.port}") + + # Add built-in gRPC service and user-defined services to the server. + # We pass a mock servicer because the actual implementation will be overwritten + # in the gRPCGenericServer implementation. + mock_servicer = Mock() + for servicer_fn in [ + add_RayServeAPIServiceServicer_to_server + ] + grpc_options.grpc_servicer_func_callable: + servicer_fn(mock_servicer, server) + + await server.start() + return event_loop.create_task(server.wait_for_termination()) + + +def get_grpc_response_status( + exc: BaseException, request_timeout_s: float, request_id: str +) -> ResponseStatus: + if isinstance(exc, TimeoutError): + message = f"Request timed out after {request_timeout_s}s." + return ResponseStatus( + code=grpc.StatusCode.DEADLINE_EXCEEDED, + is_error=True, + message=message, + ) + elif isinstance(exc, asyncio.CancelledError): + message = f"Client for request {request_id} disconnected." + return ResponseStatus( + code=grpc.StatusCode.CANCELLED, + is_error=True, + message=message, + ) + elif isinstance(exc, BackPressureError): + return ResponseStatus( + code=grpc.StatusCode.RESOURCE_EXHAUSTED, + is_error=True, + message=exc.message, + ) + elif isinstance(exc, DeploymentUnavailableError): + if isinstance(exc, RayTaskError): + logger.warning(f"Request failed: {exc}", extra={"log_to_stderr": False}) + return ResponseStatus( + code=grpc.StatusCode.UNAVAILABLE, + is_error=True, + message=exc.message, + ) + else: + if isinstance(exc, (RayActorError, RayTaskError)): + logger.warning(f"Request failed: {exc}", extra={"log_to_stderr": False}) + else: + logger.exception("Request failed due to unexpected error.") + return ResponseStatus( + code=grpc.StatusCode.INTERNAL, + is_error=True, + message=str(exc), + ) + + +def set_grpc_code_and_details( + context: grpc._cython.cygrpc._ServicerContext, status: ResponseStatus +): + # Only the latest code and details will take effect. If the user already + # set them to a truthy value in the context, skip setting them with Serve's + # default values. By default, if nothing is set, the code is 0 and the + # details is "", which both are falsy. So if the user did not set them or + # if they're explicitly set to falsy values, such as None, Serve will + # continue to set them with our default values. + if not context.code(): + context.set_code(status.code) + if not context.details(): + context.set_details(status.message) + + +def set_proxy_default_grpc_options(grpc_options) -> gRPCOptions: + grpc_options = deepcopy(grpc_options) or gRPCOptions() + + if grpc_options.request_timeout_s or RAY_SERVE_REQUEST_PROCESSING_TIMEOUT_S: + grpc_options.request_timeout_s = ( + grpc_options.request_timeout_s or RAY_SERVE_REQUEST_PROCESSING_TIMEOUT_S + ) + + return grpc_options diff --git a/lib/python3.12/site-packages/ray/serve/_private/handle_options.py b/lib/python3.12/site-packages/ray/serve/_private/handle_options.py new file mode 100644 index 0000000000000000000000000000000000000000..86ac4bc78ad27a4799652308b3b6606425017ad4 --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/_private/handle_options.py @@ -0,0 +1,76 @@ +from abc import ABC, abstractmethod +from dataclasses import dataclass, fields + +import ray +from ray.serve._private.common import DeploymentHandleSource +from ray.serve._private.constants import RAY_SERVE_RUN_ROUTER_IN_SEPARATE_LOOP +from ray.serve._private.utils import DEFAULT + + +@dataclass(frozen=True) +class InitHandleOptionsBase(ABC): + """Init options for each ServeHandle instance. + + These fields can be set by calling `.init()` on a handle before + sending the first request. + """ + + _prefer_local_routing: bool = False + _source: DeploymentHandleSource = DeploymentHandleSource.UNKNOWN + _run_router_in_separate_loop: bool = RAY_SERVE_RUN_ROUTER_IN_SEPARATE_LOOP + + @classmethod + @abstractmethod + def create(cls, **kwargs) -> "InitHandleOptionsBase": + raise NotImplementedError + + +@dataclass(frozen=True) +class InitHandleOptions(InitHandleOptionsBase): + @classmethod + def create(cls, **kwargs) -> "InitHandleOptions": + for k in list(kwargs.keys()): + if kwargs[k] == DEFAULT.VALUE: + # Use default value + del kwargs[k] + + # Detect replica source for handles + if ( + "_source" not in kwargs + and ray.serve.context._get_internal_replica_context() is not None + ): + kwargs["_source"] = DeploymentHandleSource.REPLICA + + return cls(**kwargs) + + +@dataclass(frozen=True) +class DynamicHandleOptionsBase(ABC): + """Dynamic options for each ServeHandle instance. + + These fields can be changed by calling `.options()` on a handle. + """ + + method_name: str = "__call__" + multiplexed_model_id: str = "" + stream: bool = False + + @abstractmethod + def copy_and_update(self, **kwargs) -> "DynamicHandleOptionsBase": + pass + + +@dataclass(frozen=True) +class DynamicHandleOptions(DynamicHandleOptionsBase): + _by_reference: bool = True + + def copy_and_update(self, **kwargs) -> "DynamicHandleOptions": + new_kwargs = {} + + for f in fields(self): + if f.name not in kwargs or kwargs[f.name] == DEFAULT.VALUE: + new_kwargs[f.name] = getattr(self, f.name) + else: + new_kwargs[f.name] = kwargs[f.name] + + return DynamicHandleOptions(**new_kwargs) diff --git a/lib/python3.12/site-packages/ray/serve/_private/http_util.py b/lib/python3.12/site-packages/ray/serve/_private/http_util.py new file mode 100644 index 0000000000000000000000000000000000000000..bca096e6f03e007056c5e3e6ce22b26d0701fb8a --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/_private/http_util.py @@ -0,0 +1,854 @@ +import asyncio +import inspect +import json +import logging +import pickle +import socket +from collections import deque +from copy import deepcopy +from dataclasses import dataclass +from typing import ( + Any, + AsyncGenerator, + Awaitable, + Callable, + List, + Optional, + Tuple, + Type, + Union, +) + +import starlette +import uvicorn +from fastapi import FastAPI +from fastapi.encoders import jsonable_encoder +from packaging import version +from starlette.datastructures import MutableHeaders +from starlette.middleware import Middleware +from starlette.types import ASGIApp, Message, Receive, Scope, Send +from uvicorn.config import Config +from uvicorn.lifespan.on import LifespanOn + +from ray._common.network_utils import is_ipv6 +from ray._common.pydantic_compat import IS_PYDANTIC_2 +from ray.exceptions import RayActorError, RayTaskError +from ray.serve._private.common import RequestMetadata +from ray.serve._private.constants import ( + RAY_SERVE_HTTP_KEEP_ALIVE_TIMEOUT_S, + RAY_SERVE_HTTP_PROXY_CALLBACK_IMPORT_PATH, + RAY_SERVE_REQUEST_PROCESSING_TIMEOUT_S, + SERVE_HTTP_REQUEST_ID_HEADER, + SERVE_LOGGER_NAME, +) +from ray.serve._private.proxy_request_response import ResponseStatus +from ray.serve._private.utils import ( + call_function_from_import_path, + generate_request_id, + serve_encoders, +) +from ray.serve.config import HTTPOptions +from ray.serve.exceptions import ( + BackPressureError, + DeploymentUnavailableError, + RayServeException, +) + +logger = logging.getLogger(SERVE_LOGGER_NAME) + + +@dataclass(frozen=True) +class ASGIArgs: + scope: Scope + receive: Receive + send: Send + + def to_args_tuple(self) -> Tuple[Scope, Receive, Send]: + return (self.scope, self.receive, self.send) + + def to_starlette_request(self) -> starlette.requests.Request: + return starlette.requests.Request( + *self.to_args_tuple(), + ) + + +def make_buffered_asgi_receive(serialized_body: bytes) -> Receive: + """Returns an ASGI receiver that returns the provided buffered body.""" + + # Simulates receiving HTTP body from TCP socket. In reality, the body has + # already been streamed in chunks and stored in serialized_body. + received = False + + async def mock_receive(): + nonlocal received + + # If the request has already been received, starlette will keep polling + # for HTTP disconnect. We will pause forever. The coroutine should be + # cancelled by starlette after the response has been sent. + if received: + block_forever = asyncio.Event() + await block_forever.wait() + + received = True + return {"body": serialized_body, "type": "http.request", "more_body": False} + + return mock_receive + + +def convert_object_to_asgi_messages( + obj: Optional[Any] = None, status_code: int = 200 +) -> List[Message]: + """Serializes the provided object and converts it to ASGI messages. + + These ASGI messages can be sent via an ASGI `send` interface to comprise an HTTP + response. + """ + body = None + content_type = None + if obj is None: + body = b"" + content_type = b"text/plain" + elif isinstance(obj, bytes): + body = obj + content_type = b"text/plain" + elif isinstance(obj, str): + body = obj.encode("utf-8") + content_type = b"text/plain; charset=utf-8" + else: + # `separators=(",", ":")` will remove all whitespaces between separators in the + # json string and return a minimized json string. This helps to reduce the size + # of the response similar to Starlette's JSONResponse. + body = json.dumps( + jsonable_encoder(obj, custom_encoder=serve_encoders), + separators=(",", ":"), + ).encode() + content_type = b"application/json" + + return [ + { + "type": "http.response.start", + "status": status_code, + "headers": [[b"content-type", content_type]], + }, + {"type": "http.response.body", "body": body}, + ] + + +class Response: + """ASGI compliant response class. + + It is expected to be called in async context and pass along + `scope, receive, send` as in ASGI spec. + + >>> from ray.serve.http_util import Response # doctest: +SKIP + >>> scope, receive = ... # doctest: +SKIP + >>> await Response({"k": "v"}).send(scope, receive, send) # doctest: +SKIP + """ + + def __init__(self, content=None, status_code=200): + """Construct a HTTP Response based on input type. + + Args: + content: Any JSON serializable object. + status_code (int, optional): Default status code is 200. + """ + self._messages = convert_object_to_asgi_messages( + obj=content, + status_code=status_code, + ) + + async def send(self, scope, receive, send): + for message in self._messages: + await send(message) + + +async def receive_http_body(scope, receive, send): + body_buffer = [] + more_body = True + while more_body: + message = await receive() + assert message["type"] == "http.request" + + more_body = message["more_body"] + body_buffer.append(message["body"]) + + return b"".join(body_buffer) + + +class MessageQueue(Send): + """Queue enables polling for received or sent messages. + + Implements the ASGI `Send` interface. + + This class: + - Is *NOT* thread safe and should only be accessed from a single asyncio + event loop. + - Assumes a single consumer of the queue (concurrent calls to + `get_messages_nowait` and `wait_for_message` is undefined behavior). + """ + + def __init__(self): + self._message_queue = deque() + self._new_message_event = asyncio.Event() + self._closed = False + self._error = None + + def close(self): + """Close the queue, rejecting new messages. + + Once the queue is closed, existing messages will be returned from + `get_messages_nowait` and subsequent calls to `wait_for_message` will + always return immediately. + """ + self._closed = True + self._new_message_event.set() + + def set_error(self, e: BaseException): + self._error = e + + def put_nowait(self, message: Message): + self._message_queue.append(message) + self._new_message_event.set() + + async def __call__(self, message: Message): + """Send a message, putting it on the queue. + + `RuntimeError` is raised if the queue has been closed using `.close()`. + """ + if self._closed: + raise RuntimeError("New messages cannot be sent after the queue is closed.") + + self.put_nowait(message) + + async def wait_for_message(self): + """Wait until at least one new message is available. + + If a message is available, this method will return immediately on each call + until `get_messages_nowait` is called. + + After the queue is closed using `.close()`, this will always return + immediately. + """ + if not self._closed: + await self._new_message_event.wait() + + def get_messages_nowait(self) -> List[Message]: + """Returns all messages that are currently available (non-blocking). + + At least one message will be present if `wait_for_message` had previously + returned and a subsequent call to `wait_for_message` blocks until at + least one new message is available. + """ + messages = [] + while len(self._message_queue) > 0: + messages.append(self._message_queue.popleft()) + + self._new_message_event.clear() + return messages + + async def get_one_message(self) -> Message: + """This blocks until a message is ready. + + This method should not be used together with get_messages_nowait. + Please use either `get_one_message` or `get_messages_nowait`. + + Raises: + StopAsyncIteration: if the queue is closed and there are no + more messages. + Exception (self._error): if there are no more messages in + the queue and an error has been set. + """ + + if self._error: + raise self._error + + await self._new_message_event.wait() + + if len(self._message_queue) > 0: + msg = self._message_queue.popleft() + + if len(self._message_queue) == 0 and not self._closed: + self._new_message_event.clear() + + return msg + elif len(self._message_queue) == 0 and self._error: + raise self._error + elif len(self._message_queue) == 0 and self._closed: + raise StopAsyncIteration + + async def fetch_messages_from_queue( + self, call_fut: asyncio.Future + ) -> AsyncGenerator[List[Any], None]: + """Repeatedly consume messages from the queue and yield them. + + This is used to fetch queue messages in the system event loop in + a thread-safe manner. + + Args: + call_fut: The async Future pointing to the task from the user + code event loop that is pushing messages onto the queue. + + Yields: + List[Any]: Messages from the queue. + """ + # Repeatedly consume messages from the queue. + wait_for_msg_task = None + try: + while True: + wait_for_msg_task = asyncio.create_task(self.wait_for_message()) + done, _ = await asyncio.wait( + [call_fut, wait_for_msg_task], return_when=asyncio.FIRST_COMPLETED + ) + + messages = self.get_messages_nowait() + if messages: + yield messages + + # Exit once `call_fut` has finished. In this case, all + # messages must have already been sent. + if call_fut in done: + break + + e = call_fut.exception() + if e is not None: + raise e from None + finally: + if not call_fut.done(): + call_fut.cancel() + + if wait_for_msg_task is not None and not wait_for_msg_task.done(): + wait_for_msg_task.cancel() + + +class ASGIReceiveProxy: + """Proxies ASGI receive from an actor. + + The `receive_asgi_messages` callback will be called repeatedly to fetch messages + until a disconnect message is received. + """ + + def __init__( + self, + scope: Scope, + request_metadata: RequestMetadata, + receive_asgi_messages: Callable[[RequestMetadata], Awaitable[bytes]], + ): + self._type = scope["type"] # Either 'http' or 'websocket'. + # Lazy init the queue to ensure it is created in the user code event loop. + self._queue = None + self._request_metadata = request_metadata + self._receive_asgi_messages = receive_asgi_messages + self._disconnect_message = None + + def _get_default_disconnect_message(self) -> Message: + """Return the appropriate disconnect message based on the connection type. + + HTTP ASGI spec: + https://asgi.readthedocs.io/en/latest/specs/www.html#disconnect-receive-event + + WS ASGI spec: + https://asgi.readthedocs.io/en/latest/specs/www.html#disconnect-receive-event-ws + """ + if self._type == "websocket": + return { + "type": "websocket.disconnect", + # 1005 is the default disconnect code according to the ASGI spec. + "code": 1005, + } + else: + return {"type": "http.disconnect"} + + @property + def queue(self) -> asyncio.Queue: + if self._queue is None: + self._queue = asyncio.Queue() + + return self._queue + + async def fetch_until_disconnect(self): + """Fetch messages repeatedly until a disconnect message is received. + + If a disconnect message is received, this function exits and returns it. + + If an exception occurs, it will be raised on the next __call__ and no more + messages will be received. + """ + while True: + try: + pickled_messages = await self._receive_asgi_messages( + self._request_metadata + ) + for message in pickle.loads(pickled_messages): + self.queue.put_nowait(message) + + if message["type"] in {"http.disconnect", "websocket.disconnect"}: + self._disconnect_message = message + return + except KeyError: + # KeyError can be raised if the request is no longer active in the proxy + # (i.e., the user disconnects). This is expected behavior and we should + # not log an error: https://github.com/ray-project/ray/issues/43290. + message = self._get_default_disconnect_message() + self.queue.put_nowait(message) + self._disconnect_message = message + return + except Exception as e: + # Raise unexpected exceptions in the next `__call__`. + self.queue.put_nowait(e) + return + + async def __call__(self) -> Message: + """Return the next message once available. + + This will repeatedly return a disconnect message once it's been received. + """ + if self.queue.empty() and self._disconnect_message is not None: + return self._disconnect_message + + message = await self.queue.get() + if isinstance(message, Exception): + raise message + + return message + + +def make_fastapi_class_based_view(fastapi_app, cls: Type) -> None: + """Transform the `cls`'s methods and class annotations to FastAPI routes. + + Modified from + https://github.com/dmontagu/fastapi-utils/blob/master/fastapi_utils/cbv.py + + Usage: + >>> from fastapi import FastAPI + >>> app = FastAPI() # doctest: +SKIP + >>> class A: # doctest: +SKIP + ... @app.route("/{i}") # doctest: +SKIP + ... def func(self, i: int) -> str: # doctest: +SKIP + ... return self.dep + i # doctest: +SKIP + >>> # just running the app won't work, here. + >>> make_fastapi_class_based_view(app, A) # doctest: +SKIP + >>> # now app can be run properly + """ + # Delayed import to prevent ciruclar imports in workers. + from fastapi import APIRouter, Depends + from fastapi.routing import APIRoute, APIWebSocketRoute + + async def get_current_servable_instance(): + from ray import serve + + return serve.get_replica_context().servable_object + + # Find all the class method routes + class_method_routes = [ + route + for route in fastapi_app.routes + if + # User defined routes must all be APIRoute or APIWebSocketRoute. + isinstance(route, (APIRoute, APIWebSocketRoute)) + # We want to find the route that's bound to the `cls`. + # NOTE(simon): we can't use `route.endpoint in inspect.getmembers(cls)` + # because the FastAPI supports different routes for the methods with + # same name. See #17559. + and (cls.__qualname__ in route.endpoint.__qualname__) + ] + + # Modify these routes and mount it to a new APIRouter. + # We need to to this (instead of modifying in place) because we want to use + # the laster fastapi_app.include_router to re-run the dependency analysis + # for each routes. + new_router = APIRouter() + for route in class_method_routes: + fastapi_app.routes.remove(route) + + # This block just adds a default values to the self parameters so that + # FastAPI knows to inject the object when calling the route. + # Before: def method(self, i): ... + # After: def method(self=Depends(...), *, i):... + old_endpoint = route.endpoint + old_signature = inspect.signature(old_endpoint) + old_parameters = list(old_signature.parameters.values()) + if len(old_parameters) == 0: + # TODO(simon): make it more flexible to support no arguments. + raise RayServeException( + "Methods in FastAPI class-based view must have ``self`` as " + "their first argument." + ) + old_self_parameter = old_parameters[0] + new_self_parameter = old_self_parameter.replace( + default=Depends(get_current_servable_instance) + ) + new_parameters = [new_self_parameter] + [ + # Make the rest of the parameters keyword only because + # the first argument is no longer positional. + parameter.replace(kind=inspect.Parameter.KEYWORD_ONLY) + for parameter in old_parameters[1:] + ] + new_signature = old_signature.replace(parameters=new_parameters) + route.endpoint.__signature__ = new_signature + route.endpoint._serve_cls = cls + new_router.routes.append(route) + fastapi_app.include_router(new_router) + + routes_to_remove = list() + for route in fastapi_app.routes: + if not isinstance(route, (APIRoute, APIWebSocketRoute)): + continue + + # If there is a response model, FastAPI creates a copy of the fields. + # But FastAPI creates the field incorrectly by missing the outer_type_. + if ( + # TODO(edoakes): I don't think this check is complete because we need + # to support v1 models in v2 (from pydantic.v1 import *). + not IS_PYDANTIC_2 + and isinstance(route, APIRoute) + and route.response_model + ): + route.secure_cloned_response_field.outer_type_ = ( + route.response_field.outer_type_ + ) + + # Remove endpoints that belong to other class based views. + serve_cls = getattr(route.endpoint, "_serve_cls", None) + if serve_cls is not None and serve_cls != cls: + routes_to_remove.append(route) + fastapi_app.routes[:] = [r for r in fastapi_app.routes if r not in routes_to_remove] + + +def set_socket_reuse_port(sock: socket.socket) -> bool: + """Mutate a socket object to allow multiple process listening on the same port. + + Returns: + success: whether the setting was successful. + """ + try: + # These two socket options will allow multiple process to bind the the + # same port. Kernel will evenly load balance among the port listeners. + # Note: this will only work on Linux. + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + if hasattr(socket, "SO_REUSEPORT"): + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) + # In some Python binary distribution (e.g., conda py3.6), this flag + # was not present at build time but available in runtime. But + # Python relies on compiler flag to include this in binary. + # Therefore, in the absence of socket.SO_REUSEPORT, we try + # to use `15` which is value in linux kernel. + # https://github.com/torvalds/linux/blob/master/tools/include/uapi/asm-generic/socket.h#L27 + else: + sock.setsockopt(socket.SOL_SOCKET, 15, 1) + return True + except Exception as e: + logger.debug( + f"Setting SO_REUSEPORT failed because of {e}. SO_REUSEPORT is disabled." + ) + return False + + +class ASGIAppReplicaWrapper: + """Provides a common wrapper for replicas running an ASGI app.""" + + def __init__(self, app_or_func: Union[ASGIApp, Callable]): + if inspect.isfunction(app_or_func): + self._asgi_app = app_or_func() + else: + self._asgi_app = app_or_func + + # Use uvicorn's lifespan handling code to properly deal with + # startup and shutdown event. + # If log_config is not None, uvicorn will use the default logger. + # and that interferes with our logging setup. + self._serve_asgi_lifespan = LifespanOn( + Config( + self._asgi_app, + lifespan="on", + log_level=None, + log_config=None, + access_log=False, + ) + ) + + # Replace uvicorn logger with our own. + self._serve_asgi_lifespan.logger = logger + + @property + def app(self) -> ASGIApp: + return self._asgi_app + + @property + def docs_path(self) -> Optional[str]: + if isinstance(self._asgi_app, FastAPI): + return self._asgi_app.docs_url + + async def _run_asgi_lifespan_startup(self): + # LifespanOn's logger logs in INFO level thus becomes spammy + # Within this block we temporarily uplevel for cleaner logging + from ray.serve._private.logging_utils import LoggingContext + + with LoggingContext(self._serve_asgi_lifespan.logger, level=logging.WARNING): + await self._serve_asgi_lifespan.startup() + if self._serve_asgi_lifespan.should_exit: + raise RuntimeError( + "ASGI lifespan startup failed. Check replica logs for details." + ) + + async def __call__( + self, + scope: Scope, + receive: Receive, + send: Send, + ) -> Optional[ASGIApp]: + """Calls into the wrapped ASGI app.""" + await self._asgi_app( + scope, + receive, + send, + ) + + # NOTE: __del__ must be async so that we can run ASGI shutdown + # in the same event loop. + async def __del__(self): + # LifespanOn's logger logs in INFO level thus becomes spammy. + # Within this block we temporarily uplevel for cleaner logging. + from ray.serve._private.logging_utils import LoggingContext + + with LoggingContext(self._serve_asgi_lifespan.logger, level=logging.WARNING): + await self._serve_asgi_lifespan.shutdown() + + +def validate_http_proxy_callback_return( + middlewares: Any, +) -> [Middleware]: + """Validate the return value of HTTP proxy callback. + + Middlewares should be a list of Starlette middlewares. If it is None, we + will treat it as an empty list. If it is not a list, we will raise an + error. If it is a list, we will check if all the items in the list are + Starlette middlewares. + """ + + if middlewares is None: + middlewares = [] + if not isinstance(middlewares, list): + raise ValueError( + "HTTP proxy callback must return a list of Starlette middlewares." + ) + else: + # All middlewares must be Starlette middlewares. + # https://www.starlette.io/middleware/#using-pure-asgi-middleware + for middleware in middlewares: + if not issubclass(type(middleware), Middleware): + raise ValueError( + "HTTP proxy callback must return a list of Starlette middlewares, " + f"instead got {type(middleware)} type item in the list." + ) + return middlewares + + +class RequestIdMiddleware: + def __init__(self, app: ASGIApp): + self._app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send): + headers = MutableHeaders(scope=scope) + request_id = headers.get(SERVE_HTTP_REQUEST_ID_HEADER) + + if request_id is None: + request_id = generate_request_id() + headers.append(SERVE_HTTP_REQUEST_ID_HEADER, request_id) + + async def send_with_request_id(message: Message): + if message["type"] == "http.response.start": + headers = MutableHeaders(scope=message) + headers.append("X-Request-ID", request_id) + if message["type"] == "websocket.accept": + message["X-Request-ID"] = request_id + await send(message) + + await self._app(scope, receive, send_with_request_id) + + +def _apply_middlewares(app: ASGIApp, middlewares: List[Callable]) -> ASGIApp: + """Wrap the ASGI app with the provided middlewares. + + The built-in RequestIdMiddleware will always be applied first. + """ + for middleware in [Middleware(RequestIdMiddleware)] + middlewares: + if version.parse(starlette.__version__) < version.parse("0.35.0"): + app = middleware.cls(app, **middleware.options) + else: + # In starlette >= 0.35.0, middleware.options does not exist: + # https://github.com/encode/starlette/pull/2381. + app = middleware.cls( + app, + *middleware.args, + **middleware.kwargs, + ) + + return app + + +async def start_asgi_http_server( + app: ASGIApp, + http_options: HTTPOptions, + *, + event_loop: asyncio.AbstractEventLoop, + enable_so_reuseport: bool = False, +) -> asyncio.Task: + """Start an HTTP server to run the ASGI app. + + Returns a task that blocks until the server exits (e.g., due to error). + """ + app = _apply_middlewares(app, http_options.middlewares) + + sock = socket.socket( + socket.AF_INET6 if is_ipv6(http_options.host) else socket.AF_INET, + socket.SOCK_STREAM, + ) + if enable_so_reuseport: + set_socket_reuse_port(sock) + + try: + sock.bind((http_options.host, http_options.port)) + except OSError as e: + raise RuntimeError( + f"Failed to bind to address '{http_options.host}:{http_options.port}'." + ) from e + + # Even though we set log_level=None, uvicorn adds MessageLoggerMiddleware + # if log level for uvicorn.error is not set. And MessageLoggerMiddleware + # has no use to us. + logging.getLogger("uvicorn.error").level = logging.CRITICAL + + # Configure SSL if certificates are provided + ssl_kwargs = {} + if http_options.ssl_keyfile and http_options.ssl_certfile: + ssl_kwargs = { + "ssl_keyfile": http_options.ssl_keyfile, + "ssl_certfile": http_options.ssl_certfile, + } + if http_options.ssl_keyfile_password: + ssl_kwargs["ssl_keyfile_password"] = http_options.ssl_keyfile_password + if http_options.ssl_ca_certs: + ssl_kwargs["ssl_ca_certs"] = http_options.ssl_ca_certs + + logger.info( + f"Starting HTTPS server on {http_options.host}:{http_options.port} " + f"with SSL certificate: {http_options.ssl_certfile}" + ) + + # NOTE: We have to use lower level uvicorn Config and Server + # class because we want to run the server as a coroutine. The only + # alternative is to call uvicorn.run which is blocking. + server = uvicorn.Server( + config=uvicorn.Config( + lambda: app, + factory=True, + host=http_options.host, + port=http_options.port, + root_path=http_options.root_path, + timeout_keep_alive=http_options.keep_alive_timeout_s, + loop=event_loop, + lifespan="off", + access_log=False, + log_level=None, + log_config=None, + **ssl_kwargs, + ) + ) + + # NOTE(edoakes): we need to override install_signal_handlers here + # because the existing implementation fails if it isn't running in + # the main thread and uvicorn doesn't expose a way to configure it. + server.install_signal_handlers = lambda: None + + return event_loop.create_task(server.serve(sockets=[sock])) + + +def get_http_response_status( + exc: BaseException, request_timeout_s: float, request_id: str +) -> ResponseStatus: + if isinstance(exc, TimeoutError): + return ResponseStatus( + code=408, + is_error=True, + message=f"Request {request_id} timed out after {request_timeout_s}s.", + ) + + elif isinstance(exc, asyncio.CancelledError): + message = f"Client for request {request_id} disconnected, cancelling request." + logger.info(message) + return ResponseStatus( + code=499, + is_error=True, + message=message, + ) + elif isinstance(exc, (BackPressureError, DeploymentUnavailableError)): + if isinstance(exc, RayTaskError): + logger.warning(f"Request failed: {exc}", extra={"log_to_stderr": False}) + return ResponseStatus( + code=503, + is_error=True, + message=exc.message, + ) + else: + if isinstance(exc, (RayActorError, RayTaskError)): + logger.warning(f"Request failed: {exc}", extra={"log_to_stderr": False}) + else: + logger.exception("Request failed due to unexpected error.") + return ResponseStatus( + code=500, + is_error=True, + message=str(exc), + ) + + +def send_http_response_on_exception( + status: ResponseStatus, response_started: bool +) -> List[Message]: + if response_started or status.code not in (408, 503): + return [] + return convert_object_to_asgi_messages( + status.message, + status_code=status.code, + ) + + +def configure_http_options_with_defaults(http_options: HTTPOptions) -> HTTPOptions: + """Enhanced configuration with component-specific options.""" + + http_options = deepcopy(http_options) + + # Apply environment defaults + if (RAY_SERVE_HTTP_KEEP_ALIVE_TIMEOUT_S or 0) > 0: + http_options.keep_alive_timeout_s = RAY_SERVE_HTTP_KEEP_ALIVE_TIMEOUT_S + + # TODO: Deprecate SERVE_REQUEST_PROCESSING_TIMEOUT_S env var + if http_options.request_timeout_s or RAY_SERVE_REQUEST_PROCESSING_TIMEOUT_S: + http_options.request_timeout_s = ( + http_options.request_timeout_s or RAY_SERVE_REQUEST_PROCESSING_TIMEOUT_S + ) + + http_options.middlewares = http_options.middlewares or [] + + return http_options + + +def configure_http_middlewares(http_options: HTTPOptions) -> HTTPOptions: + http_options = deepcopy(http_options) + + # Add environment variable middleware + if RAY_SERVE_HTTP_PROXY_CALLBACK_IMPORT_PATH: + logger.info( + f"Calling user-provided callback from import path " + f"'{RAY_SERVE_HTTP_PROXY_CALLBACK_IMPORT_PATH}'." + ) + + # noinspection PyTypeChecker + http_options.middlewares.extend( + validate_http_proxy_callback_return( + call_function_from_import_path( + RAY_SERVE_HTTP_PROXY_CALLBACK_IMPORT_PATH + ) + ) + ) + + return http_options diff --git a/lib/python3.12/site-packages/ray/serve/_private/local_testing_mode.py b/lib/python3.12/site-packages/ray/serve/_private/local_testing_mode.py new file mode 100644 index 0000000000000000000000000000000000000000..22cc545a538b1296226abcabc54e01f5712ed36a --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/_private/local_testing_mode.py @@ -0,0 +1,345 @@ +import asyncio +import concurrent.futures +import inspect +import logging +import queue +import time +from functools import wraps +from typing import Any, Callable, Coroutine, Dict, Optional, Tuple, Union + +import ray +from ray import cloudpickle +from ray.serve._private.common import DeploymentID, RequestMetadata +from ray.serve._private.constants import ( + RAY_SERVE_RUN_SYNC_IN_THREADPOOL, + SERVE_LOGGER_NAME, +) +from ray.serve._private.replica import UserCallableWrapper +from ray.serve._private.replica_result import ReplicaResult +from ray.serve._private.router import Router +from ray.serve._private.utils import GENERATOR_COMPOSITION_NOT_SUPPORTED_ERROR +from ray.serve.deployment import Deployment +from ray.serve.exceptions import RequestCancelledError +from ray.serve.handle import ( + DeploymentHandle, + DeploymentResponse, + DeploymentResponseGenerator, +) + +logger = logging.getLogger(SERVE_LOGGER_NAME) + + +def _validate_deployment_options( + deployment: Deployment, + deployment_id: DeploymentID, +): + if "num_gpus" in deployment.ray_actor_options: + logger.warning( + f"Deployment {deployment_id} has num_gpus configured. " + "CUDA_VISIBLE_DEVICES is not managed automatically in local testing mode. " + ) + + if "runtime_env" in deployment.ray_actor_options: + logger.warning( + f"Deployment {deployment_id} has runtime_env configured. " + "runtime_envs are ignored in local testing mode." + ) + + +def make_local_deployment_handle( + deployment: Deployment, + app_name: str, +) -> DeploymentHandle: + """Constructs an in-process DeploymentHandle. + + This is used in the application build process for local testing mode, + where all deployments of an app run in the local process which enables + faster dev iterations and use of tooling like PDB. + + The user callable will be run on an asyncio loop in a separate thread + (sharing the same code that's used in the replica). + + The constructor for the user callable is run eagerly in this function to + ensure that any exceptions are raised during `serve.run`. + """ + deployment_id = DeploymentID(deployment.name, app_name) + _validate_deployment_options(deployment, deployment_id) + user_callable_wrapper = UserCallableWrapper( + deployment.func_or_class, + deployment.init_args, + deployment.init_kwargs, + deployment_id=deployment_id, + run_sync_methods_in_threadpool=RAY_SERVE_RUN_SYNC_IN_THREADPOOL, + run_user_code_in_separate_thread=True, + local_testing_mode=True, + deployment_config=deployment._deployment_config, + ) + try: + logger.info(f"Initializing local replica class for {deployment_id}.") + user_callable_wrapper.initialize_callable().result() + user_callable_wrapper.call_reconfigure(deployment.user_config, rank=0) + except Exception: + logger.exception(f"Failed to initialize deployment {deployment_id}.") + raise + + def _create_local_router( + handle_id: str, deployment_id: DeploymentID, handle_options: Any + ) -> Router: + return LocalRouter( + user_callable_wrapper, + deployment_id=deployment_id, + handle_options=handle_options, + ) + + return DeploymentHandle( + deployment.name, + app_name, + _create_router=_create_local_router, + ) + + +class LocalReplicaResult(ReplicaResult): + """ReplicaResult used by in-process Deployment Handles.""" + + OBJ_REF_NOT_SUPPORTED_ERROR = RuntimeError( + "Converting DeploymentResponses to ObjectRefs is not supported " + "in local testing mode." + ) + REJECTION_NOT_SUPPORTED_ERROR = RuntimeError( + "Request rejection is not supported in local testing mode." + ) + + def __init__( + self, + future: concurrent.futures.Future, + *, + request_id: str, + is_streaming: bool = False, + generator_result_queue: Optional[queue.Queue] = None, + ): + self._future = future + self._lazy_asyncio_future = None + self._request_id = request_id + self._is_streaming = is_streaming + + # For streaming requests, results must be written to this queue. + # The queue will be consumed until the future is completed. + self._generator_result_queue = generator_result_queue + if self._is_streaming: + assert ( + self._generator_result_queue is not None + ), "generator_result_queue must be provided for streaming results." + + @property + def _asyncio_future(self) -> asyncio.Future: + if self._lazy_asyncio_future is None: + self._lazy_asyncio_future = asyncio.wrap_future(self._future) + + return self._lazy_asyncio_future + + def _process_response(f: Union[Callable, Coroutine]): + @wraps(f) + def wrapper(self, *args, **kwargs): + try: + return f(self, *args, **kwargs) + except (asyncio.CancelledError, concurrent.futures.CancelledError): + raise RequestCancelledError(self._request_id) + + @wraps(f) + async def async_wrapper(self, *args, **kwargs): + try: + return await f(self, *args, **kwargs) + except (asyncio.CancelledError, concurrent.futures.CancelledError): + raise RequestCancelledError(self._request_id) + + if inspect.iscoroutinefunction(f): + return async_wrapper + else: + return wrapper + + @_process_response + async def get_rejection_response(self): + raise self.REJECTION_NOT_SUPPORTED_ERROR + + @_process_response + def get(self, timeout_s: Optional[float]): + assert ( + not self._is_streaming + ), "get() can only be called on a non-streaming result." + + try: + return self._future.result(timeout=timeout_s) + except concurrent.futures.TimeoutError: + raise TimeoutError("Timed out waiting for result.") + + @_process_response + async def get_async(self): + assert ( + not self._is_streaming + ), "get_async() can only be called on a non-streaming result." + + return await self._asyncio_future + + @_process_response + def __next__(self): + assert self._is_streaming, "next() can only be called on a streaming result." + + while True: + if self._future.done() and self._generator_result_queue.empty(): + if self._future.exception(): + raise self._future.exception() + else: + raise StopIteration + + try: + return self._generator_result_queue.get(timeout=0.01) + except queue.Empty: + pass + + @_process_response + async def __anext__(self): + assert self._is_streaming, "anext() can only be called on a streaming result." + + # This callback does not pull from the queue, only checks that a result is + # available, else there is a race condition where the future finishes and the + # queue is empty, but this function hasn't returned the result yet. + def _wait_for_result(): + while True: + if self._future.done() or not self._generator_result_queue.empty(): + return + time.sleep(0.01) + + wait_for_result_task = asyncio.get_running_loop().create_task( + asyncio.to_thread(_wait_for_result), + ) + done, _ = await asyncio.wait( + [self._asyncio_future, wait_for_result_task], + return_when=asyncio.FIRST_COMPLETED, + ) + + if not self._generator_result_queue.empty(): + return self._generator_result_queue.get() + + if self._asyncio_future.exception(): + raise self._asyncio_future.exception() + + raise StopAsyncIteration + + def add_done_callback(self, callback: Callable): + self._future.add_done_callback(callback) + + def cancel(self): + self._future.cancel() + + def to_object_ref(self, timeout_s: Optional[float]) -> ray.ObjectRef: + raise self.OBJ_REF_NOT_SUPPORTED_ERROR + + async def to_object_ref_async(self) -> ray.ObjectRef: + raise self.OBJ_REF_NOT_SUPPORTED_ERROR + + def to_object_ref_gen(self) -> ray.ObjectRefGenerator: + raise self.OBJ_REF_NOT_SUPPORTED_ERROR + + +class LocalRouter(Router): + def __init__( + self, + user_callable_wrapper: UserCallableWrapper, + deployment_id: DeploymentID, + handle_options: Any, + ): + self._deployment_id = deployment_id + self._user_callable_wrapper = user_callable_wrapper + assert ( + self._user_callable_wrapper._callable is not None + ), "User callable must already be initialized." + + def running_replicas_populated(self) -> bool: + return True + + def _resolve_deployment_responses( + self, request_args: Tuple[Any], request_kwargs: Dict[str, Any] + ) -> Tuple[Tuple[Any], Dict[str, Any]]: + """Replace DeploymentResponse objects with their results. + + NOTE(edoakes): this currently calls the blocking `.result()` method + on the responses to resolve them to their values. This is a divergence + from the remote codepath where they're resolved concurrently. + """ + + def _new_arg(arg: Any) -> Any: + if isinstance(arg, DeploymentResponse): + new_arg = arg.result(_skip_asyncio_check=True) + elif isinstance(arg, DeploymentResponseGenerator): + raise GENERATOR_COMPOSITION_NOT_SUPPORTED_ERROR + else: + new_arg = arg + + return new_arg + + # Serialize and deserialize the arguments to mimic remote call behavior. + return cloudpickle.loads( + cloudpickle.dumps( + ( + tuple(_new_arg(arg) for arg in request_args), + {k: _new_arg(v) for k, v in request_kwargs.items()}, + ) + ) + ) + + def assign_request( + self, + request_meta: RequestMetadata, + *request_args, + **request_kwargs, + ) -> concurrent.futures.Future[LocalReplicaResult]: + request_args, request_kwargs = self._resolve_deployment_responses( + request_args, request_kwargs + ) + + if request_meta.is_streaming: + generator_result_queue = queue.Queue() + + def generator_result_callback(item: Any): + generator_result_queue.put_nowait(item) + + else: + generator_result_queue = None + generator_result_callback = None + + # Conform to the router interface of returning a future to the ReplicaResult. + if request_meta.is_http_request: + fut = self._user_callable_wrapper._call_http_entrypoint( + request_meta, + request_args, + request_kwargs, + generator_result_callback=generator_result_callback, + ) + elif request_meta.is_streaming: + fut = self._user_callable_wrapper._call_user_generator( + request_meta, + request_args, + request_kwargs, + enqueue=generator_result_callback, + ) + else: + fut = self._user_callable_wrapper.call_user_method( + request_meta, + request_args, + request_kwargs, + ) + noop_future = concurrent.futures.Future() + noop_future.set_result( + LocalReplicaResult( + fut, + request_id=request_meta.request_id, + is_streaming=request_meta.is_streaming, + generator_result_queue=generator_result_queue, + ) + ) + return noop_future + + def shutdown(self): + noop_future = concurrent.futures.Future() + noop_future.set_result(None) + return noop_future diff --git a/lib/python3.12/site-packages/ray/serve/_private/logging_utils.py b/lib/python3.12/site-packages/ray/serve/_private/logging_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..521b675610a28e95e204f86f84bd24e3a3efb5f8 --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/_private/logging_utils.py @@ -0,0 +1,520 @@ +import builtins +import logging +import os +import sys +import traceback +from typing import Any, Optional + +import ray +from ray._common.filters import CoreContextFilter +from ray._common.formatters import JSONFormatter, TextFormatter +from ray._common.ray_constants import LOGGING_ROTATE_BACKUP_COUNT, LOGGING_ROTATE_BYTES +from ray.serve._private.common import ServeComponentType +from ray.serve._private.constants import ( + RAY_SERVE_ENABLE_JSON_LOGGING, + RAY_SERVE_ENABLE_MEMORY_PROFILING, + RAY_SERVE_LOG_TO_STDERR, + SERVE_LOG_APPLICATION, + SERVE_LOG_COMPONENT, + SERVE_LOG_COMPONENT_ID, + SERVE_LOG_DEPLOYMENT, + SERVE_LOG_LEVEL_NAME, + SERVE_LOG_MESSAGE, + SERVE_LOG_RECORD_FORMAT, + SERVE_LOG_REPLICA, + SERVE_LOG_REQUEST_ID, + SERVE_LOG_ROUTE, + SERVE_LOG_TIME, + SERVE_LOG_UNWANTED_ATTRS, + SERVE_LOGGER_NAME, +) +from ray.serve._private.utils import get_component_file_name +from ray.serve.schema import EncodingType, LoggingConfig + +buildin_print = builtins.print + + +def should_skip_context_filter(record: logging.LogRecord) -> bool: + """Check if the log record should skip the context filter.""" + return getattr(record, "skip_context_filter", False) + + +class ServeCoreContextFilter(CoreContextFilter): + def filter(self, record: logging.LogRecord) -> bool: + if should_skip_context_filter(record): + return True + return super().filter(record) + + +class ServeComponentFilter(logging.Filter): + """Serve component filter. + + The filter will add the component name, id, and type to the log record. + """ + + def __init__( + self, + component_name: str, + component_id: str, + component_type: Optional[ServeComponentType] = None, + ): + self.component_name = component_name + self.component_id = component_id + self.component_type = component_type + + def filter(self, record: logging.LogRecord) -> bool: + """Add component attributes to the log record. + + Note: the filter doesn't do any filtering, it only adds the component + attributes. + """ + if should_skip_context_filter(record): + return True + if self.component_type and self.component_type == ServeComponentType.REPLICA: + setattr(record, SERVE_LOG_DEPLOYMENT, self.component_name) + setattr(record, SERVE_LOG_REPLICA, self.component_id) + setattr(record, SERVE_LOG_COMPONENT, self.component_type) + else: + setattr(record, SERVE_LOG_COMPONENT, self.component_name) + setattr(record, SERVE_LOG_COMPONENT_ID, self.component_id) + + return True + + +class ServeContextFilter(logging.Filter): + """Serve context filter. + + The filter will add the route, request id, app name to the log record. + + Note: the filter doesn't do any filtering, it only adds the serve request context + attributes. + """ + + def filter(self, record): + if should_skip_context_filter(record): + return True + + request_context = ray.serve.context._get_serve_request_context() + if request_context.route: + setattr(record, SERVE_LOG_ROUTE, request_context.route) + if request_context.request_id: + setattr(record, SERVE_LOG_REQUEST_ID, request_context.request_id) + if request_context.app_name: + setattr(record, SERVE_LOG_APPLICATION, request_context.app_name) + return True + + +class ServeLogAttributeRemovalFilter(logging.Filter): + """Serve log attribute removal filter. + + The filter will remove unwanted attributes on the log record so they won't be + included in the structured logs. + + Note: the filter doesn't do any filtering, it only removes unwanted attributes. + """ + + def filter(self, record): + for key in SERVE_LOG_UNWANTED_ATTRS: + if hasattr(record, key): + delattr(record, key) + + return True + + +class ServeFormatter(TextFormatter): + """Serve Logging Formatter + + The formatter will generate the log format on the fly based on the field of record. + Optimized to pre-compute format strings and formatters for better performance. + """ + + COMPONENT_LOG_FMT = f"%({SERVE_LOG_LEVEL_NAME})s %({SERVE_LOG_TIME})s {{{SERVE_LOG_COMPONENT}}} {{{SERVE_LOG_COMPONENT_ID}}} " # noqa:E501 + + def __init__( + self, + component_name: str, + component_id: str, + fmt: Optional[str] = None, + datefmt: Optional[str] = None, + style: str = "%", + validate: bool = True, + ): + super().__init__(fmt, datefmt, style, validate) + self.component_log_fmt = ServeFormatter.COMPONENT_LOG_FMT.format( + component_name=component_name, component_id=component_id + ) + + # Pre-compute format strings and formatters for performance + self._precompute_formatters() + + def set_additional_log_standard_attrs(self, *args, **kwargs): + super().set_additional_log_standard_attrs(*args, **kwargs) + self._precompute_formatters() + + def _precompute_formatters(self): + self.base_formatter = self._create_formatter([]) + self.request_formatter = self._create_formatter( + [SERVE_LOG_RECORD_FORMAT[SERVE_LOG_REQUEST_ID]] + ) + + def _create_formatter(self, initial_attrs: list) -> logging.Formatter: + attrs = initial_attrs.copy() + attrs.extend([f"%({k})s" for k in self.additional_log_standard_attrs]) + attrs.append(SERVE_LOG_RECORD_FORMAT[SERVE_LOG_MESSAGE]) + + format_string = self.component_log_fmt + " ".join(attrs) + return logging.Formatter(format_string) + + def format(self, record: logging.LogRecord) -> str: + """Format the log record into the format string. + + Args: + record: The log record to be formatted. + Returns: + The formatted log record in string format. + """ + # Use pre-computed formatters for better performance + if SERVE_LOG_REQUEST_ID in record.__dict__: + return self.request_formatter.format(record) + else: + return self.base_formatter.format(record) + + +def access_log_msg(*, method: str, route: str, status: str, latency_ms: float): + """Returns a formatted message for an HTTP or ServeHandle access log.""" + return f"{method} {route} {status} {latency_ms:.1f}ms" + + +def log_to_stderr_filter(record: logging.LogRecord) -> bool: + """Filters log records based on a parameter in the `extra` dictionary.""" + if not hasattr(record, "log_to_stderr") or record.log_to_stderr is None: + return True + + return record.log_to_stderr + + +def log_access_log_filter(record: logging.LogRecord) -> bool: + """Filters ray serve access log based on 'serve_access_log' key in `extra` dict.""" + if not hasattr(record, "serve_access_log") or record.serve_access_log is None: + return True + + return not record.serve_access_log + + +def get_component_logger_file_path() -> Optional[str]: + """Returns the relative file path for the Serve logger, if it exists. + + If a logger was configured through configure_component_logger() for the Serve + component that's calling this function, this returns the location of the log file + relative to the ray logs directory. + """ + logger = logging.getLogger(SERVE_LOGGER_NAME) + for handler in logger.handlers: + if isinstance(handler, logging.handlers.MemoryHandler): + absolute_path = handler.target.baseFilename + ray_logs_dir = ray._private.worker._global_node.get_logs_dir_path() + if absolute_path.startswith(ray_logs_dir): + return absolute_path[len(ray_logs_dir) :] + + +class StreamToLogger(object): + """ + Fake file-like stream object that redirects writes to a logger instance. + + This comes from https://stackoverflow.com/a/36296215 directly. + """ + + def __init__(self, logger: logging.Logger, log_level: int, original_object: Any): + self._logger = logger + self._log_level = log_level + self._original_object = original_object + self._linebuf = "" + + def __getattr__(self, attr: str) -> Any: + # getting attributes from the original object + return getattr(self._original_object, attr) + + @staticmethod + def get_stacklevel() -> int: + """Rewind stack to get the stacklevel for the user code. + + Going from the back of the traceback and traverse until it's no longer in + logging_utils.py or site-packages. + """ + reverse_traces = traceback.extract_stack()[::-1] + for index, trace in enumerate(reverse_traces): + if ( + "logging_utils.py" not in trace.filename + and "site-packages" not in trace.filename + ): + return index + return 1 + + def write(self, buf: str): + temp_linebuf = self._linebuf + buf + self._linebuf = "" + for line in temp_linebuf.splitlines(True): + # From the io.TextIOWrapper docs: + # On output, if newline is None, any '\n' characters written + # are translated to the system default line separator. + # By default sys.stdout.write() expects '\n' newlines and then + # translates them so this is still cross-platform. + if line[-1] == "\n": + self._logger.log( + self._log_level, + line.rstrip(), + stacklevel=self.get_stacklevel(), + ) + else: + self._linebuf += line + + def flush(self): + if self._linebuf != "": + self._logger.log( + self._log_level, + self._linebuf.rstrip(), + stacklevel=self.get_stacklevel(), + ) + self._linebuf = "" + + def isatty(self) -> bool: + return True + + +def redirected_print(*objects, sep=" ", end="\n", file=None, flush=False): + """Implement python's print function to redirect logs to Serve's logger. + + If the file is set to anything other than stdout, stderr, or None, call the + builtin print. Else, construct the message and redirect to Serve's logger. + + See https://docs.python.org/3/library/functions.html#print + """ + if file not in [sys.stdout, sys.stderr, None]: + return buildin_print(objects, sep=sep, end=end, file=file, flush=flush) + + serve_logger = logging.getLogger(SERVE_LOGGER_NAME) + message = sep.join(map(str, objects)) + end + # We monkey patched print function, so this is always at stack level 2. + serve_logger.log(logging.INFO, message, stacklevel=2) + + +def configure_component_logger( + *, + component_name: str, + component_id: str, + logging_config: LoggingConfig, + component_type: Optional[ServeComponentType] = None, + max_bytes: Optional[int] = None, + backup_count: Optional[int] = None, + stream_handler_only: bool = False, + buffer_size: int = 1, +): + """Configure a logger to be used by a Serve component. + + The logger will log using a standard format to make components identifiable + using the provided name and unique ID for this instance (e.g., replica ID). + + This logger will *not* propagate its log messages to the parent logger(s). + """ + logger = logging.getLogger(SERVE_LOGGER_NAME) + logger.propagate = False + logger.setLevel(logging_config.log_level) + logger.handlers.clear() + + serve_formatter = ServeFormatter(component_name, component_id) + json_formatter = JSONFormatter() + if logging_config.additional_log_standard_attrs: + json_formatter.set_additional_log_standard_attrs( + logging_config.additional_log_standard_attrs + ) + serve_formatter.set_additional_log_standard_attrs( + logging_config.additional_log_standard_attrs + ) + + # Only add stream handler if RAY_SERVE_LOG_TO_STDERR is True or if + # `stream_handler_only` is set to True. + if RAY_SERVE_LOG_TO_STDERR or stream_handler_only: + stream_handler = logging.StreamHandler() + stream_handler.setFormatter(serve_formatter) + stream_handler.addFilter(log_to_stderr_filter) + stream_handler.addFilter(ServeContextFilter()) + logger.addHandler(stream_handler) + + # Skip setting up file handler and stdout/stderr redirect if `stream_handler_only` + # is set to True. Logger such as default serve logger can be configured outside the + # context of a Serve component, we don't want those logs to redirect into serve's + # logger and log files. + if stream_handler_only: + return + + if logging_config.logs_dir: + logs_dir = logging_config.logs_dir + else: + logs_dir = get_serve_logs_dir() + os.makedirs(logs_dir, exist_ok=True) + + if max_bytes is None: + max_bytes = ray._private.worker._global_node.max_bytes + if backup_count is None: + backup_count = ray._private.worker._global_node.backup_count + + log_file_name = get_component_file_name( + component_name=component_name, + component_id=component_id, + component_type=component_type, + suffix=".log", + ) + + file_handler = logging.handlers.RotatingFileHandler( + os.path.join(logs_dir, log_file_name), + maxBytes=max_bytes, + backupCount=backup_count, + ) + # Create a memory handler that buffers log records and flushes to file handler + # Buffer capacity: buffer_size records + # Flush triggers: buffer full, ERROR messages, or explicit flush + memory_handler = logging.handlers.MemoryHandler( + capacity=buffer_size, + target=file_handler, + flushLevel=logging.ERROR, # Auto-flush on ERROR/CRITICAL + ) + if RAY_SERVE_ENABLE_JSON_LOGGING: + logger.warning( + "'RAY_SERVE_ENABLE_JSON_LOGGING' is deprecated, please use " + "'LoggingConfig' to enable json format." + ) + # Add filters directly to the memory handler effective for both buffered and non buffered cases + if RAY_SERVE_ENABLE_JSON_LOGGING or logging_config.encoding == EncodingType.JSON: + memory_handler.addFilter(ServeCoreContextFilter()) + memory_handler.addFilter(ServeContextFilter()) + memory_handler.addFilter( + ServeComponentFilter(component_name, component_id, component_type) + ) + file_handler.setFormatter(json_formatter) + else: + file_handler.setFormatter(serve_formatter) + + if logging_config.enable_access_log is False: + memory_handler.addFilter(log_access_log_filter) + else: + memory_handler.addFilter(ServeContextFilter()) + + # Remove unwanted attributes from the log record. + memory_handler.addFilter(ServeLogAttributeRemovalFilter()) + + # Redirect print, stdout, and stderr to Serve logger, only when it's on the replica. + if not RAY_SERVE_LOG_TO_STDERR and component_type == ServeComponentType.REPLICA: + builtins.print = redirected_print + sys.stdout = StreamToLogger(logger, logging.INFO, sys.stdout) + sys.stderr = StreamToLogger(logger, logging.INFO, sys.stderr) + + # Add the memory handler instead of the file handler directly + logger.addHandler(memory_handler) + + +def configure_default_serve_logger(): + """Helper function to configure the default Serve logger that's used outside of + individual Serve components.""" + configure_component_logger( + component_name="serve", + component_id=str(os.getpid()), + logging_config=LoggingConfig(), + max_bytes=LOGGING_ROTATE_BYTES, + backup_count=LOGGING_ROTATE_BACKUP_COUNT, + stream_handler_only=True, + ) + + +def configure_component_memory_profiler( + component_name: str, + component_id: str, + component_type: Optional[ServeComponentType] = None, +): + """Configures the memory logger for this component. + + Does nothing if RAY_SERVE_ENABLE_MEMORY_PROFILING is disabled. + """ + + if RAY_SERVE_ENABLE_MEMORY_PROFILING: + logger = logging.getLogger(SERVE_LOGGER_NAME) + + try: + import memray + + logs_dir = get_serve_logs_dir() + memray_file_name = get_component_file_name( + component_name=component_name, + component_id=component_id, + component_type=component_type, + suffix="_memray_0.bin", + ) + memray_file_path = os.path.join(logs_dir, memray_file_name) + + # If the actor restarted, memray requires a new file to start + # tracking memory. + restart_counter = 1 + while os.path.exists(memray_file_path): + memray_file_name = get_component_file_name( + component_name=component_name, + component_id=component_id, + component_type=component_type, + suffix=f"_memray_{restart_counter}.bin", + ) + memray_file_path = os.path.join(logs_dir, memray_file_name) + restart_counter += 1 + + # Memray usually tracks the memory usage of only a block of code + # within a context manager. We explicitly call __enter__ here + # instead of using a context manager to track memory usage across + # all of the caller's code instead. + memray.Tracker(memray_file_path, native_traces=True).__enter__() + + logger.info( + "RAY_SERVE_ENABLE_MEMORY_PROFILING is enabled. Started a " + "memray tracker on this actor. Tracker file located at " + f'"{memray_file_path}"' + ) + + except ImportError: + logger.warning( + "RAY_SERVE_ENABLE_MEMORY_PROFILING is enabled, but memray " + "is not installed. No memory profiling is happening. " + "`pip install memray` to enable memory profiling." + ) + + +def get_serve_logs_dir() -> str: + """Get the directory that stores Serve log files. + + If `ray._private.worker._global_node` is None (running outside the context of Ray), + then the current working directory with subdirectory of serve is used as the logs + directory. Otherwise, the logs directory is determined by the global node's logs + directory path. + """ + if ray._private.worker._global_node is None: + return os.path.join(os.getcwd(), "serve") + + return os.path.join(ray._private.worker._global_node.get_logs_dir_path(), "serve") + + +class LoggingContext: + """ + Context manager to manage logging behaviors within a particular block, such as: + 1) Overriding logging level + + Source (python3 official documentation) + https://docs.python.org/3/howto/logging-cookbook.html#using-a-context-manager-for-selective-logging # noqa: E501 + """ + + def __init__(self, logger, level=None): + self.logger = logger + self.level = level + + def __enter__(self): + if self.level is not None: + self.old_level = self.logger.level + self.logger.setLevel(self.level) + + def __exit__(self, et, ev, tb): + if self.level is not None: + self.logger.setLevel(self.old_level) diff --git a/lib/python3.12/site-packages/ray/serve/_private/long_poll.py b/lib/python3.12/site-packages/ray/serve/_private/long_poll.py new file mode 100644 index 0000000000000000000000000000000000000000..ca09d55bc8a5e07dedb9fe18e190ec14e3409a4d --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/_private/long_poll.py @@ -0,0 +1,488 @@ +import asyncio +import contextvars +import logging +import os +import random +from asyncio import sleep +from asyncio.events import AbstractEventLoop +from collections import defaultdict +from collections.abc import Mapping +from dataclasses import dataclass +from enum import Enum, auto +from typing import Any, Callable, DefaultDict, Dict, Optional, Set, Tuple, Union + +import ray +from ray._common.utils import get_or_create_event_loop +from ray.serve._private.constants import SERVE_LOGGER_NAME +from ray.serve.generated.serve_pb2 import ( + DeploymentTargetInfo, + EndpointInfo as EndpointInfoProto, + EndpointSet, + LongPollRequest, + LongPollResult, + UpdatedObject as UpdatedObjectProto, +) +from ray.util import metrics + +logger = logging.getLogger(SERVE_LOGGER_NAME) + +# Each LongPollClient will send requests to LongPollHost to poll changes +# as blocking awaitable. This doesn't scale if we have many client instances +# that will slow down, or even block controller actor's event loop if near +# its max_concurrency limit. Therefore we timeout a polling request after +# a few seconds and let each client retry on their end. +# We randomly select a timeout within this range to avoid a "thundering herd" +# when there are many clients subscribing at the same time. +LISTEN_FOR_CHANGE_REQUEST_TIMEOUT_S = ( + float(os.environ.get("LISTEN_FOR_CHANGE_REQUEST_TIMEOUT_S_LOWER_BOUND", "30")), + float(os.environ.get("LISTEN_FOR_CHANGE_REQUEST_TIMEOUT_S_UPPER_BOUND", "60")), +) + + +class LongPollNamespace(Enum): + def __repr__(self): + return f"{self.__class__.__name__}.{self.name}" + + DEPLOYMENT_TARGETS = auto() + ROUTE_TABLE = auto() + GLOBAL_LOGGING_CONFIG = auto() + DEPLOYMENT_CONFIG = auto() + + +@dataclass +class UpdatedObject: + object_snapshot: Any + # The identifier for the object's version. There is not sequential relation + # among different object's snapshot_ids. + snapshot_id: int + + +# Type signature for the update state callbacks. E.g. +# async def update_state(updated_object: Any): +# do_something(updated_object) +UpdateStateCallable = Callable[[Any], None] +KeyType = Union[str, LongPollNamespace, Tuple[LongPollNamespace, str]] + + +class LongPollState(Enum): + TIME_OUT = auto() + + +class LongPollClient: + """The asynchronous long polling client. + + Args: + host_actor: handle to actor embedding LongPollHost. + key_listeners: a dictionary mapping keys to + callbacks to be called on state update for the corresponding keys. + call_in_event_loop: an asyncio event loop + to post the callback into. + """ + + def __init__( + self, + host_actor, + key_listeners: Dict[KeyType, UpdateStateCallable], + call_in_event_loop: AbstractEventLoop, + ) -> None: + # We used to allow this to be optional, but due to Ray Client issue + # we now enforce all long poll client to post callback to event loop + # See https://github.com/ray-project/ray/issues/20971 + assert call_in_event_loop is not None + + self.host_actor = host_actor + self.key_listeners = key_listeners + self.event_loop = call_in_event_loop + self.snapshot_ids: Dict[KeyType, int] = { + # The initial snapshot id for each key is < 0, + # but real snapshot keys in the long poll host are always >= 0, + # so this will always trigger an initial update. + key: -1 + for key in self.key_listeners.keys() + } + self.is_running = True + + # NOTE(edoakes): we schedule the initial _poll_next call with an empty context + # so that Ray will not recursively cancel the underlying `listen_for_change` + # task. See: https://github.com/ray-project/ray/issues/52476. + self.event_loop.call_soon_threadsafe( + self._poll_next, context=contextvars.Context() + ) + + def stop(self) -> None: + """Stop the long poll client after the next RPC returns.""" + self.is_running = False + + def add_key_listeners( + self, key_listeners: Dict[KeyType, UpdateStateCallable] + ) -> None: + """Add more key listeners to the client. + The new listeners will only be included in the *next* long poll request; + the current request will continue with the existing listeners. + + If a key is already in the client, the new listener will replace the old one, + but the snapshot ID will be preserved, so the new listener will only be called + on the *next* update to that key. + """ + # We need to run the underlying method in the same event loop that runs + # the long poll loop, because we need to mutate the mapping of snapshot IDs, + # which also needs to be serialized by the long poll's RPC to the + # Serve Controller. If those happened concurrently in different threads, + # we could get a `RuntimeError: dictionary changed size during iteration`. + # See https://github.com/ray-project/ray/pull/52793 for more details. + self.event_loop.call_soon_threadsafe(self._add_key_listeners, key_listeners) + + def _add_key_listeners( + self, key_listeners: Dict[KeyType, UpdateStateCallable] + ) -> None: + """Inner method that actually adds the key listeners, to be called + via call_soon_threadsafe for thread safety. + """ + # Only initialize snapshot ids for *new* keys. + self.snapshot_ids.update( + {key: -1 for key in key_listeners.keys() if key not in self.key_listeners} + ) + self.key_listeners.update(key_listeners) + + def _on_callback_completed(self, trigger_at: int): + """Called after a single callback is completed. + + When the total number of callback completed equals to trigger_at, + _poll_next() will be called. This is designed to make sure we only + _poll_next() after all the state callbacks completed. This is a + way to serialize the callback invocations between object versions. + """ + self._callbacks_processed_count += 1 + if self._callbacks_processed_count == trigger_at: + self._poll_next() + + def _poll_next(self): + """Poll the update. The callback is expected to scheduler another + _poll_next call. + """ + if not self.is_running: + return + + self._callbacks_processed_count = 0 + self._current_ref = self.host_actor.listen_for_change.remote(self.snapshot_ids) + self._current_ref._on_completed(lambda update: self._process_update(update)) + + def _schedule_to_event_loop(self, callback): + # Schedule the next iteration only if the loop is running. + # The event loop might not be running if users used a cached + # version across loops. + if self.event_loop.is_running(): + self.event_loop.call_soon_threadsafe(callback) + else: + logger.error("The event loop is closed, shutting down long poll client.") + self.is_running = False + + def _process_update(self, updates: Dict[str, UpdatedObject]): + if isinstance(updates, (ray.exceptions.RayActorError)): + # This can happen during shutdown where the controller is + # intentionally killed, the client should just gracefully + # exit. + logger.debug("LongPollClient failed to connect to host. Shutting down.") + self.is_running = False + return + + if isinstance(updates, ConnectionError): + logger.warning("LongPollClient connection failed, shutting down.") + self.is_running = False + return + + if isinstance(updates, (ray.exceptions.RayTaskError)): + # Some error happened in the controller. It could be a bug or + # some undesired state. + logger.error("LongPollHost errored\n" + updates.traceback_str) + # We must call this in event loop so it works in Ray Client. + # See https://github.com/ray-project/ray/issues/20971 + self._schedule_to_event_loop(self._poll_next) + return + + if updates == LongPollState.TIME_OUT: + logger.debug("LongPollClient polling timed out. Retrying.") + self._schedule_to_event_loop(self._poll_next) + return + + logger.debug( + f"LongPollClient {self} received updates for keys: " + f"{list(updates.keys())}.", + extra={"log_to_stderr": False}, + ) + if not updates: # no updates, no callbacks to run, just poll again + self._schedule_to_event_loop(self._poll_next) + for key, update in updates.items(): + self.snapshot_ids[key] = update.snapshot_id + callback = self.key_listeners[key] + + # Bind the parameters because closures are late-binding. + # https://docs.python-guide.org/writing/gotchas/#late-binding-closures # noqa: E501 + def chained(callback=callback, arg=update.object_snapshot): + callback(arg) + self._on_callback_completed(trigger_at=len(updates)) + + self._schedule_to_event_loop(chained) + + +class LongPollHost: + """The server side object that manages long pulling requests. + + The desired use case is to embed this in an Ray actor. Client will be + expected to call actor.listen_for_change.remote(...). On the host side, + you can call host.notify_changed({key: object}) to update the state and + potentially notify whoever is polling for these values. + + Internally, we use snapshot_ids for each object to identify client with + outdated object and immediately return the result. If the client has the + up-to-date version, then the listen_for_change call will only return when + the object is updated. + """ + + def __init__( + self, + listen_for_change_request_timeout_s: Tuple[ + int, int + ] = LISTEN_FOR_CHANGE_REQUEST_TIMEOUT_S, + ): + # Map object_key -> int + self.snapshot_ids: Dict[KeyType, int] = {} + # Map object_key -> object + self.object_snapshots: Dict[KeyType, Any] = {} + # Map object_key -> set(asyncio.Event waiting for updates) + self.notifier_events: DefaultDict[KeyType, Set[asyncio.Event]] = defaultdict( + set + ) + + self._listen_for_change_request_timeout_s = listen_for_change_request_timeout_s + self.transmission_counter = metrics.Counter( + "serve_long_poll_host_transmission_counter", + description="The number of times the long poll host transmits data.", + tag_keys=("namespace_or_state",), + ) + + def _get_num_notifier_events(self, key: Optional[KeyType] = None): + """Used for testing.""" + if key is not None: + return len(self.notifier_events[key]) + else: + return sum(len(events) for events in self.notifier_events.values()) + + def _count_send( + self, timeout_or_data: Union[LongPollState, Dict[KeyType, UpdatedObject]] + ): + """Helper method that tracks the data sent by listen_for_change. + + Records number of times long poll host sends data in the + ray_serve_long_poll_host_send_counter metric. + """ + + if isinstance(timeout_or_data, LongPollState): + # The only LongPollState is TIME_OUT– the long poll + # connection has timed out. + self.transmission_counter.inc( + value=1, tags={"namespace_or_state": "TIMEOUT"} + ) + else: + data = timeout_or_data + for key in data.keys(): + self.transmission_counter.inc( + value=1, tags={"namespace_or_state": str(key)} + ) + + async def listen_for_change( + self, + keys_to_snapshot_ids: Dict[KeyType, int], + ) -> Union[LongPollState, Dict[KeyType, UpdatedObject]]: + """Listen for changed objects. + + This method will return a dictionary of updated objects. It returns + immediately if any of the snapshot_ids are outdated, + otherwise it will block until there's an update. + """ + # If there are no keys to listen for, + # just wait for a short time to provide backpressure, + # then return an empty update. + if not keys_to_snapshot_ids: + await sleep(1) + + updated_objects = {} + self._count_send(updated_objects) + return updated_objects + + # If there are any keys with outdated snapshot ids, + # return their updated values immediately. + updated_objects = {} + for key, client_snapshot_id in keys_to_snapshot_ids.items(): + try: + existing_id = self.snapshot_ids[key] + except KeyError: + # The caller may ask for keys that we don't know about (yet), + # just ignore them. + # This can happen when, for example, + # a deployment handle is manually created for an app + # that hasn't been deployed yet (by bypassing the safety checks). + continue + + if existing_id != client_snapshot_id: + updated_objects[key] = UpdatedObject( + self.object_snapshots[key], existing_id + ) + if len(updated_objects) > 0: + self._count_send(updated_objects) + return updated_objects + + # Otherwise, register asyncio events to be waited. + async_task_to_events = {} + async_task_to_watched_keys = {} + for key in keys_to_snapshot_ids.keys(): + # Create a new asyncio event for this key. + event = asyncio.Event() + + # Make sure future caller of notify_changed will unblock this + # asyncio Event. + self.notifier_events[key].add(event) + + task = get_or_create_event_loop().create_task(event.wait()) + async_task_to_events[task] = event + async_task_to_watched_keys[task] = key + + done, not_done = await asyncio.wait( + async_task_to_watched_keys.keys(), + return_when=asyncio.FIRST_COMPLETED, + timeout=random.uniform(*self._listen_for_change_request_timeout_s), + ) + + for task in not_done: + task.cancel() + try: + event = async_task_to_events[task] + self.notifier_events[async_task_to_watched_keys[task]].remove(event) + except KeyError: + # Because we use `FIRST_COMPLETED` above, a task in `not_done` may + # actually have had its event removed in `notify_changed`. + pass + + if len(done) == 0: + self._count_send(LongPollState.TIME_OUT) + return LongPollState.TIME_OUT + else: + updated_objects = {} + for task in done: + updated_object_key = async_task_to_watched_keys[task] + updated_objects[updated_object_key] = UpdatedObject( + self.object_snapshots[updated_object_key], + self.snapshot_ids[updated_object_key], + ) + self._count_send(updated_objects) + return updated_objects + + async def listen_for_change_java( + self, + keys_to_snapshot_ids_bytes: bytes, + ) -> bytes: + """Listen for changed objects. only call by java proxy/router now. + Args: + keys_to_snapshot_ids_bytes (Dict[str, int]): the protobuf bytes of + keys_to_snapshot_ids (Dict[str, int]). + """ + request_proto = LongPollRequest.FromString(keys_to_snapshot_ids_bytes) + keys_to_snapshot_ids = { + self._parse_xlang_key(xlang_key): snapshot_id + for xlang_key, snapshot_id in request_proto.keys_to_snapshot_ids.items() + } + keys_to_updated_objects = await self.listen_for_change(keys_to_snapshot_ids) + return self._listen_result_to_proto_bytes(keys_to_updated_objects) + + def _parse_poll_namespace(self, name: str): + if name == LongPollNamespace.ROUTE_TABLE.name: + return LongPollNamespace.ROUTE_TABLE + elif name == LongPollNamespace.DEPLOYMENT_TARGETS.name: + return LongPollNamespace.DEPLOYMENT_TARGETS + else: + return name + + def _parse_xlang_key(self, xlang_key: str) -> KeyType: + if xlang_key is None: + raise ValueError("func _parse_xlang_key: xlang_key is None") + if xlang_key.startswith("(") and xlang_key.endswith(")"): + fields = xlang_key[1:-1].split(",") + if len(fields) == 2: + enum_field = self._parse_poll_namespace(fields[0].strip()) + if isinstance(enum_field, LongPollNamespace): + return enum_field, fields[1].strip() + else: + return self._parse_poll_namespace(xlang_key) + raise ValueError("can not parse key type from xlang_key {}".format(xlang_key)) + + def _build_xlang_key(self, key: KeyType) -> str: + if isinstance(key, tuple): + return "(" + key[0].name + "," + key[1] + ")" + elif isinstance(key, LongPollNamespace): + return key.name + else: + return key + + def _object_snapshot_to_proto_bytes( + self, key: KeyType, object_snapshot: Any + ) -> bytes: + if key == LongPollNamespace.ROUTE_TABLE: + # object_snapshot is Dict[DeploymentID, EndpointInfo] + # NOTE(zcin): the endpoint dictionary broadcasted to Java + # HTTP proxies should use string as key because Java does + # not yet support 2.x or applications + xlang_endpoints = { + str(endpoint_tag): EndpointInfoProto(route=endpoint_info.route) + for endpoint_tag, endpoint_info in object_snapshot.items() + } + return EndpointSet(endpoints=xlang_endpoints).SerializeToString() + elif isinstance(key, tuple) and key[0] == LongPollNamespace.DEPLOYMENT_TARGETS: + # object_snapshot.running_replicas is List[RunningReplicaInfo] + actor_name_list = [ + replica_info.replica_id.to_full_id_str() + for replica_info in object_snapshot.running_replicas + ] + return DeploymentTargetInfo( + replica_names=actor_name_list, + is_available=object_snapshot.is_available, + ).SerializeToString() + else: + return str.encode(str(object_snapshot)) + + def _listen_result_to_proto_bytes( + self, keys_to_updated_objects: Dict[KeyType, UpdatedObject] + ) -> bytes: + xlang_keys_to_updated_objects = { + self._build_xlang_key(key): UpdatedObjectProto( + snapshot_id=updated_object.snapshot_id, + object_snapshot=self._object_snapshot_to_proto_bytes( + key, updated_object.object_snapshot + ), + ) + for key, updated_object in keys_to_updated_objects.items() + } + data = { + "updated_objects": xlang_keys_to_updated_objects, + } + proto = LongPollResult(**data) + return proto.SerializeToString() + + def notify_changed(self, updates: Mapping[KeyType, Any]) -> None: + """ + Update the current snapshot of some objects + and notify any long poll clients. + """ + for object_key, updated_object in updates.items(): + try: + self.snapshot_ids[object_key] += 1 + except KeyError: + # Initial snapshot id must be >= 0, so that the long poll client + # can send a negative initial snapshot id to get a fast update. + # They should also be randomized; see + # https://github.com/ray-project/ray/pull/45881#discussion_r1645243485 + self.snapshot_ids[object_key] = random.randint(0, 1_000_000) + self.object_snapshots[object_key] = updated_object + logger.debug(f"LongPollHost: Notify change for key {object_key}.") + + for event in self.notifier_events.pop(object_key, set()): + event.set() diff --git a/lib/python3.12/site-packages/ray/serve/_private/metrics_utils.py b/lib/python3.12/site-packages/ray/serve/_private/metrics_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..8ddb1275be7a8e365b14764e9e4d49ebb0528bf7 --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/_private/metrics_utils.py @@ -0,0 +1,482 @@ +import asyncio +import bisect +import heapq +import logging +import statistics +from collections import defaultdict +from dataclasses import dataclass +from itertools import chain +from typing import ( + Awaitable, + Callable, + DefaultDict, + Dict, + Hashable, + Iterable, + List, + Optional, + Tuple, + Union, +) + +from ray.serve._private.common import TimeSeries, TimeStampedValue +from ray.serve._private.constants import ( + METRICS_PUSHER_GRACEFUL_SHUTDOWN_TIMEOUT_S, + SERVE_LOGGER_NAME, +) +from ray.serve.config import AggregationFunction + +QUEUED_REQUESTS_KEY = "queued" + +logger = logging.getLogger(SERVE_LOGGER_NAME) + + +@dataclass +class _MetricsTask: + task_func: Union[Callable, Callable[[], Awaitable]] + interval_s: float + + +class MetricsPusher: + """Periodically runs registered asyncio tasks.""" + + def __init__( + self, + *, + async_sleep: Optional[Callable[[int], None]] = None, + ): + self._async_sleep = async_sleep or asyncio.sleep + self._tasks: Dict[str, _MetricsTask] = dict() + self._async_tasks: Dict[str, asyncio.Task] = dict() + + # The event needs to be lazily initialized because this class may be constructed + # on the main thread but its methods called on a separate asyncio loop. + self._stop_event: Optional[asyncio.Event] = None + + @property + def stop_event(self) -> asyncio.Event: + if self._stop_event is None: + self._stop_event = asyncio.Event() + + return self._stop_event + + def start(self): + self.stop_event.clear() + + async def metrics_task(self, name: str): + """Periodically runs `task_func` every `interval_s` until `stop_event` is set. + + If `task_func` raises an error, an exception will be logged. + Supports both sync and async task functions. + """ + + wait_for_stop_event = asyncio.create_task(self.stop_event.wait()) + while True: + if wait_for_stop_event.done(): + return + + try: + task_func = self._tasks[name].task_func + # Check if the function is a coroutine function + if asyncio.iscoroutinefunction(task_func): + await task_func() + else: + task_func() + except Exception as e: + logger.exception(f"Failed to run metrics task '{name}': {e}") + + sleep_task = asyncio.create_task( + self._async_sleep(self._tasks[name].interval_s) + ) + await asyncio.wait( + [sleep_task, wait_for_stop_event], + return_when=asyncio.FIRST_COMPLETED, + ) + + if not sleep_task.done(): + sleep_task.cancel() + + def register_or_update_task( + self, + name: str, + task_func: Union[Callable, Callable[[], Awaitable]], + interval_s: int, + ) -> None: + """Register a sync or async task under the provided name, or update it. + + This method is idempotent - if a task is already registered with + the specified name, it will update it with the most recent info. + + Args: + name: Unique name for the task. + task_func: Either a sync function or async function (coroutine function). + interval_s: Interval in seconds between task executions. + """ + + self._tasks[name] = _MetricsTask(task_func, interval_s) + if name not in self._async_tasks or self._async_tasks[name].done(): + self._async_tasks[name] = asyncio.create_task(self.metrics_task(name)) + + def stop_tasks(self): + self.stop_event.set() + self._tasks.clear() + self._async_tasks.clear() + + async def graceful_shutdown(self): + """Shutdown metrics pusher gracefully. + + This method will ensure idempotency of shutdown call. + """ + + self.stop_event.set() + if self._async_tasks: + await asyncio.wait( + list(self._async_tasks.values()), + timeout=METRICS_PUSHER_GRACEFUL_SHUTDOWN_TIMEOUT_S, + ) + + self._tasks.clear() + self._async_tasks.clear() + + +class InMemoryMetricsStore: + """A very simple, in memory time series database""" + + def __init__(self): + self.data: DefaultDict[Hashable, TimeSeries] = defaultdict(list) + + def add_metrics_point(self, data_points: Dict[Hashable, float], timestamp: float): + """Push new data points to the store. + + Args: + data_points: dictionary containing the metrics values. The + key should uniquely identify this time series + and to be used to perform aggregation. + timestamp: the unix epoch timestamp the metrics are + collected at. + """ + + for name, value in data_points.items(): + # Using in-sort to insert while maintaining sorted ordering. + bisect.insort(a=self.data[name], x=TimeStampedValue(timestamp, value)) + + def prune_keys_and_compact_data(self, start_timestamp_s: float): + """Prune keys and compact data that are outdated. + + For keys that haven't had new data recorded after the timestamp, + remove them from the database. + For keys that have, compact the datapoints that were recorded + before the timestamp. + """ + for key, datapoints in list(self.data.items()): + if len(datapoints) == 0 or datapoints[-1].timestamp < start_timestamp_s: + del self.data[key] + else: + self.data[key] = self._get_datapoints(key, start_timestamp_s) + + def _get_datapoints( + self, key: Hashable, window_start_timestamp_s: float + ) -> TimeSeries: + """Get all data points given key after window_start_timestamp_s""" + + datapoints = self.data[key] + + idx = bisect.bisect( + a=datapoints, + x=TimeStampedValue( + timestamp=window_start_timestamp_s, value=0 # dummy value + ), + ) + return datapoints[idx:] + + def _aggregate_reduce( + self, + keys: Iterable[Hashable], + aggregate_fn: Callable[[Iterable[float]], float], + ) -> Tuple[Optional[float], int]: + """Reduce the entire set of timeseries values across the specified keys. + + Args: + keys: Iterable of keys to aggregate across. + aggregate_fn: Function to apply across all float values, e.g., sum, max. + + Returns: + A tuple of (float, int) where the first element is the aggregated value + and the second element is the number of valid keys used. + Returns (None, 0) if no valid keys have data. + + Example: + Suppose the store contains: + >>> store = InMemoryMetricsStore() + >>> store.data.update({ + ... "a": [TimeStampedValue(0, 1.0), TimeStampedValue(1, 2.0)], + ... "b": [], + ... "c": [TimeStampedValue(0, 10.0)], + ... }) + + Using sum across keys: + + >>> store._aggregate_reduce(keys=["a", "b", "c"], aggregate_fn=sum) + (13.0, 2) + + Here: + - The aggregated value is 1.0 + 2.0 + 10.0 = 13.0 + - Only keys "a" and "c" contribute values, so report_count = 2 + """ + valid_key_count = 0 + + def _values_generator(): + """Generator that yields values from valid keys without storing them all in memory.""" + nonlocal valid_key_count + for key in keys: + series = self.data.get(key, []) + if not series: + continue + + valid_key_count += 1 + for timestamp_value in series: + yield timestamp_value.value + + # Create the generator and check if it has any values + values_gen = _values_generator() + try: + first_value = next(values_gen) + except StopIteration: + # No valid data found + return None, 0 + + # Apply aggregation to the generator (memory efficient) + aggregated_result = aggregate_fn(chain([first_value], values_gen)) + return aggregated_result, valid_key_count + + def get_latest( + self, + key: Hashable, + ) -> Optional[float]: + """Get the latest value for a given key.""" + if not self.data.get(key, None): + return None + return self.data[key][-1].value + + def aggregate_sum( + self, + keys: Iterable[Hashable], + ) -> Tuple[Optional[float], int]: + """Sum the entire set of timeseries values across the specified keys. + Args: + keys: Iterable of keys to aggregate across. + Returns: + A tuple of (float, int) where the first element is the sum across + all values found at `keys`, and the second is the number of valid + keys used to compute the sum. + Returns (None, 0) if no valid keys have data. + """ + return self._aggregate_reduce(keys, sum) + + def aggregate_avg( + self, + keys: Iterable[Hashable], + ) -> Tuple[Optional[float], int]: + """Average the entire set of timeseries values across the specified keys. + + Args: + keys: Iterable of keys to aggregate across. + Returns: + A tuple of (float, int) where the first element is the mean across + all values found at `keys`, and the second is the number of valid + keys used to compute the mean. + Returns (None, 0) if no valid keys have data. + """ + return self._aggregate_reduce(keys, statistics.mean) + + def timeseries_count( + self, + key: Hashable, + ) -> int: + """Count the number of values across all timeseries values at the specified keys.""" + series = self.data.get(key, []) + if not series: + return 0 + return len(series) + + +def time_weighted_average( + step_series: TimeSeries, + window_start: Optional[float] = None, + window_end: Optional[float] = None, + last_window_s: float = 1.0, +) -> Optional[float]: + """ + Compute time-weighted average of a step function over a time interval. + + Args: + step_series: Step function as list of (timestamp, value) points, sorted by time. + Values are right-continuous (constant until next change). + window_start: Start of averaging window (inclusive). If None, uses the start of the series. + window_end: End of averaging window (exclusive). If None, uses the end of the series. + last_window_s: when window_end is None, uses the last_window_s to compute the end of the window. + Returns: + Time-weighted average over the interval, or None if no data overlaps. + """ + if not step_series: + return None + + # Handle None values by using full timeseries bounds + if window_start is None: + window_start = step_series[0].timestamp + if window_end is None: + # Use timestamp after the last point to include the final segment + window_end = step_series[-1].timestamp + last_window_s + + if window_end <= window_start: + return None + + total_weighted_value = 0.0 + total_duration = 0.0 + current_value = 0.0 # Default if no data before window_start + current_time = window_start + + # Process each segment that overlaps with the window + for point in step_series: + if point.timestamp <= window_start: + # Find the value at window_start (LOCF) + current_value = point.value + continue + if point.timestamp >= window_end: + break # Beyond our window + + # Add contribution of current segment + segment_end = min(point.timestamp, window_end) + duration = segment_end - current_time + if duration > 0: + total_weighted_value += current_value * duration + total_duration += duration + + current_value = point.value + current_time = segment_end + + # Add final segment if it extends to window_end + if current_time < window_end: + duration = window_end - current_time + total_weighted_value += current_value * duration + total_duration += duration + + return total_weighted_value / total_duration if total_duration > 0 else None + + +def aggregate_timeseries( + timeseries: TimeSeries, + aggregation_function: AggregationFunction, + last_window_s: float = 1.0, +) -> Optional[float]: + """Aggregate the values in a timeseries using a specified function.""" + if aggregation_function == AggregationFunction.MEAN: + return time_weighted_average(timeseries, last_window_s=last_window_s) + elif aggregation_function == AggregationFunction.MAX: + return max(ts.value for ts in timeseries) if timeseries else None + elif aggregation_function == AggregationFunction.MIN: + return min(ts.value for ts in timeseries) if timeseries else None + else: + raise ValueError(f"Invalid aggregation function: {aggregation_function}") + + +def merge_instantaneous_total( + replicas_timeseries: List[TimeSeries], +) -> TimeSeries: + """ + Merge multiple gauge time series (right-continuous, LOCF) into an + instantaneous total time series as a step function. + + This approach treats each replica's gauge as right-continuous, last-observation- + carried-forward (LOCF), which matches gauge semantics. It produces an exact + instantaneous total across replicas without bias from arbitrary windowing. + + Uses a k-way merge algorithm for O(n log k) complexity where k is the number + of timeseries and n is the total number of events. + + Timestamps are rounded to 10ms precision (2 decimal places) and datapoints + with the same rounded timestamp are combined, keeping the most recent value. + + Args: + replicas_timeseries: List of time series, one per replica. Each time series + is a list of TimeStampedValue objects sorted by timestamp. + + Returns: + A list of TimeStampedValue representing the instantaneous total at event times. + Between events, the total remains constant (step function). Timestamps are + rounded to 10ms precision and duplicate timestamps are combined. + """ + # Filter out empty timeseries + active_series = [series for series in replicas_timeseries if series] + if not active_series: + return [] + + # True k-way merge: heap maintains exactly k elements (one per series) + # Each element is (timestamp, replica_id, iterator) + merge_heap = [] + current_values = [0.0] * len(active_series) # Current value for each replica (LOCF) + + # Initialize heap with first element from each series + for replica_idx, series in enumerate(active_series): + if series: # Non-empty series + iterator = iter(series) + try: + first_point = next(iterator) + heapq.heappush( + merge_heap, + (first_point.timestamp, replica_idx, first_point.value, iterator), + ) + except StopIteration: + pass + + merged: TimeSeries = [] + running_total = 0.0 + + while merge_heap: + # Pop the earliest event (heap size stays ≤ k) + timestamp, replica_idx, value, iterator = heapq.heappop(merge_heap) + + old_value = current_values[replica_idx] + current_values[replica_idx] = value + running_total += value - old_value + + # Try to get the next point from this replica's series and push it back + try: + next_point: TimeStampedValue = next(iterator) + heapq.heappush( + merge_heap, + (next_point.timestamp, replica_idx, next_point.value, iterator), + ) + except StopIteration: + pass # This series is exhausted + + # Only add a point if the total actually changed + if value != old_value: # Equivalent to new_total != old_total + # Round timestamp to 10ms precision (2 decimal places) + rounded_timestamp = round(timestamp, 2) + + # Check if we already have a point with this rounded timestamp + # If so, update its value; otherwise, add a new point + if merged and merged[-1].timestamp == rounded_timestamp: + # Update the last point's value since timestamps match + merged[-1] = TimeStampedValue(rounded_timestamp, running_total) + else: + # Add new point with rounded timestamp + merged.append(TimeStampedValue(rounded_timestamp, running_total)) + + return merged + + +def merge_timeseries_dicts( + *timeseries_dicts: DefaultDict[Hashable, TimeSeries], +) -> DefaultDict[Hashable, TimeSeries]: + """ + Merge multiple time-series dictionaries using instantaneous merge approach. + """ + merged: DefaultDict[Hashable, TimeSeries] = defaultdict(list) + + for ts_dict in timeseries_dicts: + for key, ts in ts_dict.items(): + merged[key].append(ts) + + return {key: merge_instantaneous_total(ts_list) for key, ts_list in merged.items()} diff --git a/lib/python3.12/site-packages/ray/serve/_private/proxy.py b/lib/python3.12/site-packages/ray/serve/_private/proxy.py new file mode 100644 index 0000000000000000000000000000000000000000..0e6c069a3f94b862534665257b9d9d81fe29a723 --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/_private/proxy.py @@ -0,0 +1,1391 @@ +import asyncio +import gc +import json +import logging +import os +import pickle +import time +from abc import ABC, abstractmethod +from typing import Any, Callable, Dict, Generator, Optional, Set, Tuple + +import grpc +import starlette +import starlette.routing +from packaging import version +from starlette.types import Receive + +import ray +from ray._common.filters import CoreContextFilter +from ray._common.utils import get_or_create_event_loop +from ray.serve._private.common import ( + DeploymentID, + EndpointInfo, + NodeId, + ReplicaID, + RequestMetadata, + RequestProtocol, +) +from ray.serve._private.constants import ( + HEALTHY_MESSAGE, + PROXY_MIN_DRAINING_PERIOD_S, + RAY_SERVE_ENABLE_PROXY_GC_OPTIMIZATIONS, + RAY_SERVE_PROXY_GC_THRESHOLD, + RAY_SERVE_REQUEST_PATH_LOG_BUFFER_SIZE, + REQUEST_LATENCY_BUCKETS_MS, + SERVE_CONTROLLER_NAME, + SERVE_HTTP_REQUEST_ID_HEADER, + SERVE_LOG_COMPONENT, + SERVE_LOG_COMPONENT_ID, + SERVE_LOG_REQUEST_ID, + SERVE_LOG_ROUTE, + SERVE_LOGGER_NAME, + SERVE_MULTIPLEXED_MODEL_ID, + SERVE_NAMESPACE, +) +from ray.serve._private.default_impl import get_proxy_handle +from ray.serve._private.grpc_util import ( + get_grpc_response_status, + set_grpc_code_and_details, + start_grpc_server, +) +from ray.serve._private.http_util import ( + MessageQueue, + configure_http_middlewares, + convert_object_to_asgi_messages, + get_http_response_status, + receive_http_body, + send_http_response_on_exception, + start_asgi_http_server, +) +from ray.serve._private.logging_utils import ( + access_log_msg, + configure_component_logger, + configure_component_memory_profiler, + get_component_logger_file_path, +) +from ray.serve._private.long_poll import LongPollClient, LongPollNamespace +from ray.serve._private.proxy_request_response import ( + ASGIProxyRequest, + HandlerMetadata, + ProxyRequest, + ResponseGenerator, + ResponseHandlerInfo, + ResponseStatus, + gRPCProxyRequest, +) +from ray.serve._private.proxy_response_generator import ProxyResponseGenerator +from ray.serve._private.proxy_router import ProxyRouter +from ray.serve._private.usage import ServeUsageTag +from ray.serve._private.utils import ( + generate_request_id, + get_head_node_id, + is_grpc_enabled, +) +from ray.serve.config import HTTPOptions, gRPCOptions +from ray.serve.generated.serve_pb2 import HealthzResponse, ListApplicationsResponse +from ray.serve.handle import DeploymentHandle +from ray.serve.schema import EncodingType, LoggingConfig +from ray.util import metrics + +logger = logging.getLogger(SERVE_LOGGER_NAME) + +SOCKET_REUSE_PORT_ENABLED = ( + os.environ.get("SERVE_SOCKET_REUSE_PORT_ENABLED", "1") == "1" +) + +if os.environ.get("SERVE_REQUEST_PROCESSING_TIMEOUT_S") is not None: + logger.warning( + "The `SERVE_REQUEST_PROCESSING_TIMEOUT_S` environment variable has " + "been deprecated. Please set `request_timeout_s` in your Serve config's " + "`http_options` or `grpc_options` field instead. `SERVE_REQUEST_PROCESSING_TIMEOUT_S` will be " + "ignored in future versions. See: https://docs.ray.io/en/releases-2.5.1/serve/a" + "pi/doc/ray.serve.schema.HTTPOptionsSchema.html#ray.serve.schema.HTTPOptionsSch" + "ema.request_timeout_s and https://docs.ray.io/en/latest/serve/api/" + "doc/ray.serve.config.gRPCOptions.request_timeout_s.html#" + "ray.serve.config.gRPCOptions.request_timeout_s" + ) + + +INITIAL_BACKOFF_PERIOD_SEC = 0.05 +MAX_BACKOFF_PERIOD_SEC = 5 + +DRAINING_MESSAGE = "This node is being drained." + + +class GenericProxy(ABC): + """This class is served as the base class for different types of proxies. + It contains all the common setup and methods required for running a proxy. + + The proxy subclass need to implement the following methods: + - `protocol()` + - `not_found_response()` + - `routes_response()` + - `health_response()` + - `setup_request_context_and_handle()` + - `send_request_to_replica()` + """ + + def __init__( + self, + node_id: NodeId, + node_ip_address: str, + is_head: bool, + proxy_router: ProxyRouter, + request_timeout_s: Optional[float] = None, + access_log_context: Dict[str, Any] = None, + ): + self.request_timeout_s = request_timeout_s + if self.request_timeout_s is not None and self.request_timeout_s < 0: + self.request_timeout_s = None + + self._node_id = node_id + self._is_head = is_head + + self.proxy_router = proxy_router + self.request_counter = metrics.Counter( + f"serve_num_{self.protocol.lower()}_requests", + description=f"The number of {self.protocol} requests processed.", + tag_keys=("route", "method", "application", "status_code"), + ) + + self.request_error_counter = metrics.Counter( + f"serve_num_{self.protocol.lower()}_error_requests", + description=f"The number of errored {self.protocol} responses.", + tag_keys=( + "route", + "error_code", + "method", + "application", + ), + ) + + self.deployment_request_error_counter = metrics.Counter( + f"serve_num_deployment_{self.protocol.lower()}_error_requests", + description=( + f"The number of errored {self.protocol} " + "responses returned by each deployment." + ), + tag_keys=( + "deployment", + "error_code", + "method", + "route", + "application", + ), + ) + + # log REQUEST_LATENCY_BUCKET_MS + logger.debug(f"REQUEST_LATENCY_BUCKET_MS: {REQUEST_LATENCY_BUCKETS_MS}") + self.processing_latency_tracker = metrics.Histogram( + f"serve_{self.protocol.lower()}_request_latency_ms", + description=( + f"The end-to-end latency of {self.protocol} requests " + f"(measured from the Serve {self.protocol} proxy)." + ), + boundaries=REQUEST_LATENCY_BUCKETS_MS, + tag_keys=( + "method", + "route", + "application", + "status_code", + ), + ) + + self.num_ongoing_requests_gauge = metrics.Gauge( + name=f"serve_num_ongoing_{self.protocol.lower()}_requests", + description=f"The number of ongoing requests in this {self.protocol} " + "proxy.", + tag_keys=("node_id", "node_ip_address"), + ).set_default_tags( + { + "node_id": node_id, + "node_ip_address": node_ip_address, + } + ) + + # `self._ongoing_requests` is used to count the number of ongoing requests + self._ongoing_requests = 0 + # The time when the node starts to drain. + # The node is not draining if it's None. + self._draining_start_time: Optional[float] = None + + self._access_log_context = access_log_context or {} + + getattr(ServeUsageTag, f"{self.protocol.upper()}_PROXY_USED").record("1") + + @property + @abstractmethod + def protocol(self) -> RequestProtocol: + """Protocol used in the proxy. + + Each proxy needs to implement its own logic for setting up the protocol. + """ + raise NotImplementedError + + def _is_draining(self) -> bool: + """Whether is proxy actor is in the draining status or not.""" + return self._draining_start_time is not None + + def is_drained(self): + """Check whether the proxy actor is drained or not. + + A proxy actor is drained if it has no ongoing requests + AND it has been draining for more than + `PROXY_MIN_DRAINING_PERIOD_S` seconds. + """ + if not self._is_draining(): + return False + + return (not self._ongoing_requests) and ( + (time.time() - self._draining_start_time) > PROXY_MIN_DRAINING_PERIOD_S + ) + + def update_draining(self, draining: bool): + """Update the draining status of the proxy. + + This is called by the proxy state manager + to drain or un-drain the proxy actor. + """ + + if draining and (not self._is_draining()): + logger.info( + f"Start to drain the proxy actor on node {self._node_id}.", + extra={"log_to_stderr": False}, + ) + self._draining_start_time = time.time() + if (not draining) and self._is_draining(): + logger.info( + f"Stop draining the proxy actor on node {self._node_id}.", + extra={"log_to_stderr": False}, + ) + self._draining_start_time = None + + @abstractmethod + async def not_found_response( + self, proxy_request: ProxyRequest + ) -> ResponseGenerator: + raise NotImplementedError + + @abstractmethod + async def routes_response( + self, *, healthy: bool, message: str + ) -> ResponseGenerator: + raise NotImplementedError + + @abstractmethod + async def health_response( + self, *, healthy: bool, message: str + ) -> ResponseGenerator: + raise NotImplementedError + + def _ongoing_requests_start(self): + """Ongoing requests start. + + The current autoscale logic can downscale nodes with ongoing requests if the + node doesn't have replicas and has no primary copies of objects in the object + store. The counter and the dummy object reference will help to keep the node + alive while draining requests, so they are not dropped unintentionally. + """ + self._ongoing_requests += 1 + self.num_ongoing_requests_gauge.set(self._ongoing_requests) + + def _ongoing_requests_end(self): + """Ongoing requests end. + + Decrement the ongoing request counter and drop the dummy object reference + signaling that the node can be downscaled safely. + """ + self._ongoing_requests -= 1 + self.num_ongoing_requests_gauge.set(self._ongoing_requests) + + def _get_health_or_routes_reponse( + self, proxy_request: ProxyRequest + ) -> ResponseHandlerInfo: + """Get the response handler for system health and route endpoints. + + If the proxy is draining or has not yet received a route table update from the + controller, both will return a non-OK status. + """ + router_ready_for_traffic, router_msg = self.proxy_router.ready_for_traffic( + self._is_head + ) + if self._is_draining(): + healthy = False + message = DRAINING_MESSAGE + elif not router_ready_for_traffic: + healthy = False + message = router_msg + else: + healthy = True + message = HEALTHY_MESSAGE + + if proxy_request.is_health_request: + response_generator = self.health_response(healthy=healthy, message=message) + else: + assert proxy_request.is_route_request + response_generator = self.routes_response(healthy=healthy, message=message) + + return ResponseHandlerInfo( + response_generator=response_generator, + metadata=HandlerMetadata( + route=proxy_request.route_path, + ), + should_record_access_log=False, + should_increment_ongoing_requests=False, + ) + + def _get_response_handler_info( + self, proxy_request: ProxyRequest + ) -> ResponseHandlerInfo: + if proxy_request.is_health_request or proxy_request.is_route_request: + return self._get_health_or_routes_reponse(proxy_request) + + matched_route = None + if self.protocol == RequestProtocol.HTTP: + matched_route = self.proxy_router.match_route(proxy_request.route_path) + elif self.protocol == RequestProtocol.GRPC: + matched_route = self.proxy_router.get_handle_for_endpoint( + proxy_request.route_path + ) + + if matched_route is None: + return ResponseHandlerInfo( + response_generator=self.not_found_response(proxy_request), + metadata=HandlerMetadata( + # Don't include the invalid route prefix because it can blow up our + # metrics' cardinality. + # See: https://github.com/ray-project/ray/issues/47999 + route="", + ), + should_record_access_log=True, + should_increment_ongoing_requests=False, + ) + else: + route_prefix, handle, app_is_cross_language = matched_route + # Modify the path and root path so that reverse lookups and redirection + # work as expected. We do this here instead of in replicas so it can be + # changed without restarting the replicas. + route_path = proxy_request.route_path + if route_prefix != "/" and self.protocol == RequestProtocol.HTTP: + assert not route_prefix.endswith("/") + proxy_request.set_root_path(proxy_request.root_path + route_prefix) + # NOTE(edoakes): starlette<0.33.0 expected the ASGI 'root_prefix' + # to be stripped from the 'path', which wasn't technically following + # the standard. See https://github.com/encode/starlette/pull/2352. + if version.parse(starlette.__version__) < version.parse("0.33.0"): + proxy_request.set_path(route_path.replace(route_prefix, "", 1)) + + # NOTE(abrar): we try to match to a specific route pattern (e.g., /api/{user_id}) + # for logs & metrics when available. If no pattern matches, we fall back to the + # route_prefix to avoid high cardinality. + # See: https://github.com/ray-project/ray/issues/47999 and + # https://github.com/ray-project/ray/issues/52212 + if self.protocol == RequestProtocol.HTTP: + logs_and_metrics_route = self.proxy_router.match_route_pattern( + route_prefix, proxy_request.scope + ) + else: + logs_and_metrics_route = handle.deployment_id.app_name + internal_request_id = generate_request_id() + handle, request_id = self.setup_request_context_and_handle( + app_name=handle.deployment_id.app_name, + handle=handle, + route=logs_and_metrics_route, + proxy_request=proxy_request, + internal_request_id=internal_request_id, + ) + + response_generator = self.send_request_to_replica( + request_id=request_id, + internal_request_id=internal_request_id, + handle=handle, + proxy_request=proxy_request, + app_is_cross_language=app_is_cross_language, + ) + + return ResponseHandlerInfo( + response_generator=response_generator, + metadata=HandlerMetadata( + application_name=handle.deployment_id.app_name, + deployment_name=handle.deployment_id.name, + route=logs_and_metrics_route, + ), + should_record_access_log=True, + should_increment_ongoing_requests=True, + ) + + async def proxy_request(self, proxy_request: ProxyRequest) -> ResponseGenerator: + """Wrapper for proxy request. + + This method is served as common entry point by the proxy. It handles the + routing, including routes and health checks, ongoing request counter, + and metrics. + """ + assert proxy_request.request_type in {"http", "websocket", "grpc"} + + response_handler_info = self._get_response_handler_info(proxy_request) + + start_time = time.time() + if response_handler_info.should_increment_ongoing_requests: + self._ongoing_requests_start() + + try: + # The final message yielded must always be the `ResponseStatus`. + status: Optional[ResponseStatus] = None + async for message in response_handler_info.response_generator: + if isinstance(message, ResponseStatus): + status = message + + yield message + + assert status is not None and isinstance(status, ResponseStatus) + finally: + # If anything during the request failed, we still want to ensure the ongoing + # request counter is decremented. + if response_handler_info.should_increment_ongoing_requests: + self._ongoing_requests_end() + + latency_ms = (time.time() - start_time) * 1000.0 + if response_handler_info.should_record_access_log: + request_context = ray.serve.context._get_serve_request_context() + self._access_log_context[SERVE_LOG_ROUTE] = request_context.route + self._access_log_context[SERVE_LOG_REQUEST_ID] = request_context.request_id + logger.info( + access_log_msg( + method=proxy_request.method, + route=request_context.route, + status=str(status.code), + latency_ms=latency_ms, + ), + extra=self._access_log_context, + ) + + self.request_counter.inc( + tags={ + "route": response_handler_info.metadata.route, + "method": proxy_request.method, + "application": response_handler_info.metadata.application_name, + "status_code": str(status.code), + } + ) + + self.processing_latency_tracker.observe( + latency_ms, + tags={ + "route": response_handler_info.metadata.route, + "method": proxy_request.method, + "application": response_handler_info.metadata.application_name, + "status_code": str(status.code), + }, + ) + if status.is_error: + self.request_error_counter.inc( + tags={ + "route": response_handler_info.metadata.route, + "method": proxy_request.method, + "application": response_handler_info.metadata.application_name, + "error_code": str(status.code), + } + ) + self.deployment_request_error_counter.inc( + tags={ + "route": response_handler_info.metadata.route, + "method": proxy_request.method, + "application": response_handler_info.metadata.application_name, + "error_code": str(status.code), + "deployment": response_handler_info.metadata.deployment_name, + } + ) + + @abstractmethod + def setup_request_context_and_handle( + self, + app_name: str, + handle: DeploymentHandle, + route: str, + proxy_request: ProxyRequest, + internal_request_id: str, + ) -> Tuple[DeploymentHandle, str]: + """Setup the request context and handle for the request. + + Each proxy needs to implement its own logic for setting up the request context + and handle. + """ + raise NotImplementedError + + @abstractmethod + async def send_request_to_replica( + self, + request_id: str, + internal_request_id: str, + handle: DeploymentHandle, + proxy_request: ProxyRequest, + app_is_cross_language: bool = False, + ) -> ResponseGenerator: + """Send the request to the replica and handle streaming response. + + Each proxy needs to implement its own logic for sending the request and + handling the streaming response. + """ + raise NotImplementedError + + +class gRPCProxy(GenericProxy): + """This class is meant to be instantiated and run by an gRPC server. + + This is the servicer class for the gRPC server. It implements `unary_unary` + as the entry point for unary gRPC request and `unary_stream` as the entry + point for streaming gRPC request. + """ + + @property + def protocol(self) -> RequestProtocol: + return RequestProtocol.GRPC + + async def not_found_response( + self, proxy_request: ProxyRequest + ) -> ResponseGenerator: + if not proxy_request.app_name: + application_message = "Application metadata not set." + else: + application_message = f"Application '{proxy_request.app_name}' not found." + not_found_message = ( + f"{application_message} Ping " + "/ray.serve.RayServeAPIService/ListApplications for available applications." + ) + + yield ResponseStatus( + code=grpc.StatusCode.NOT_FOUND, + message=not_found_message, + is_error=True, + ) + + async def routes_response( + self, *, healthy: bool, message: str + ) -> ResponseGenerator: + yield ListApplicationsResponse( + application_names=[ + endpoint.app_name for endpoint in self.proxy_router.endpoints + ], + ).SerializeToString() + + yield ResponseStatus( + code=grpc.StatusCode.OK if healthy else grpc.StatusCode.UNAVAILABLE, + message=message, + is_error=not healthy, + ) + + async def health_response(self, *, healthy: bool, message) -> ResponseGenerator: + yield HealthzResponse(message=message).SerializeToString() + yield ResponseStatus( + code=grpc.StatusCode.OK if healthy else grpc.StatusCode.UNAVAILABLE, + message=message, + is_error=not healthy, + ) + + def service_handler_factory(self, service_method: str, stream: bool) -> Callable: + async def unary_unary( + request_proto: Any, context: grpc._cython.cygrpc._ServicerContext + ) -> bytes: + """Entry point of the gRPC proxy unary request. + + This method is called by the gRPC server when a unary request is received. + It wraps the request in a ProxyRequest object and calls proxy_request. + The return value is serialized user defined protobuf bytes. + """ + proxy_request = gRPCProxyRequest( + request_proto=request_proto, + context=context, + service_method=service_method, + stream=False, + ) + + status = None + response = None + async for message in self.proxy_request(proxy_request=proxy_request): + if isinstance(message, ResponseStatus): + status = message + else: + response = message + + set_grpc_code_and_details(context, status) + + return response + + async def unary_stream( + request_proto: Any, context: grpc._cython.cygrpc._ServicerContext + ) -> Generator[bytes, None, None]: + """Entry point of the gRPC proxy streaming request. + + This method is called by the gRPC server when a streaming request is + received. It wraps the request in a ProxyRequest object and calls + proxy_request. The return value is a generator of serialized user defined + protobuf bytes. + """ + proxy_request = gRPCProxyRequest( + request_proto=request_proto, + context=context, + service_method=service_method, + stream=True, + ) + + status = None + async for message in self.proxy_request(proxy_request=proxy_request): + if isinstance(message, ResponseStatus): + status = message + else: + yield message + + set_grpc_code_and_details(context, status) + + return unary_stream if stream else unary_unary + + def setup_request_context_and_handle( + self, + app_name: str, + handle: DeploymentHandle, + route: str, + proxy_request: ProxyRequest, + internal_request_id: str, + ) -> Tuple[DeploymentHandle, str]: + """Setup request context and handle for the request. + + Unpack gRPC request metadata and extract info to set up request context and + handle. + """ + multiplexed_model_id = proxy_request.multiplexed_model_id + request_id = proxy_request.request_id + if not request_id: + request_id = generate_request_id() + proxy_request.request_id = request_id + + handle = handle.options( + stream=proxy_request.stream, + multiplexed_model_id=multiplexed_model_id, + method_name=proxy_request.method_name, + ) + + request_context_info = { + "route": route, + "request_id": request_id, + "_internal_request_id": internal_request_id, + "app_name": app_name, + "multiplexed_model_id": multiplexed_model_id, + "grpc_context": proxy_request.ray_serve_grpc_context, + } + ray.serve.context._serve_request_context.set( + ray.serve.context._RequestContext(**request_context_info) + ) + proxy_request.send_request_id(request_id=request_id) + return handle, request_id + + async def send_request_to_replica( + self, + request_id: str, + internal_request_id: str, + handle: DeploymentHandle, + proxy_request: ProxyRequest, + app_is_cross_language: bool = False, + ) -> ResponseGenerator: + response_generator = ProxyResponseGenerator( + handle.remote(proxy_request.serialized_replica_arg()), + timeout_s=self.request_timeout_s, + ) + + try: + async for context, result in response_generator: + context._set_on_grpc_context(proxy_request.context) + yield result + + status = ResponseStatus(code=grpc.StatusCode.OK) + except BaseException as e: + status = get_grpc_response_status(e, self.request_timeout_s, request_id) + + # The status code should always be set. + assert status is not None + yield status + + +class HTTPProxy(GenericProxy): + """This class is meant to be instantiated and run by an ASGI HTTP server.""" + + def __init__( + self, + node_id: NodeId, + node_ip_address: str, + is_head: bool, + proxy_router: ProxyRouter, + self_actor_name: str, + request_timeout_s: Optional[float] = None, + access_log_context: Dict[str, Any] = None, + ): + super().__init__( + node_id, + node_ip_address, + is_head, + proxy_router, + request_timeout_s=request_timeout_s, + access_log_context=access_log_context, + ) + self.self_actor_name = self_actor_name + self.asgi_receive_queues: Dict[str, MessageQueue] = dict() + + @property + def protocol(self) -> RequestProtocol: + return RequestProtocol.HTTP + + async def not_found_response( + self, proxy_request: ProxyRequest + ) -> ResponseGenerator: + status_code = 404 + for message in convert_object_to_asgi_messages( + f"Path '{proxy_request.path}' not found. " + "Ping http://.../-/routes for available routes.", + status_code=status_code, + ): + yield message + + yield ResponseStatus(code=status_code, is_error=True) + + async def routes_response( + self, *, healthy: bool, message: str + ) -> ResponseGenerator: + status_code = 200 if healthy else 503 + if healthy: + response = dict() + for endpoint, info in self.proxy_router.endpoints.items(): + # For 2.x deployments, return {route -> app name} + if endpoint.app_name: + response[info.route] = endpoint.app_name + # Keep compatibility with 1.x deployments. + else: + response[info.route] = endpoint.name + else: + response = message + + for asgi_message in convert_object_to_asgi_messages( + response, + status_code=status_code, + ): + yield asgi_message + + yield ResponseStatus( + code=status_code, + message=message, + is_error=not healthy, + ) + + async def health_response( + self, *, healthy: bool, message: str = "" + ) -> ResponseGenerator: + status_code = 200 if healthy else 503 + for asgi_message in convert_object_to_asgi_messages( + message, + status_code=status_code, + ): + yield asgi_message + + yield ResponseStatus( + code=status_code, + is_error=not healthy, + message=message, + ) + + async def receive_asgi_messages( + self, request_metadata: RequestMetadata + ) -> ResponseGenerator: + queue = self.asgi_receive_queues.get(request_metadata.internal_request_id, None) + if queue is None: + raise KeyError(f"Request ID {request_metadata.request_id} not found.") + + await queue.wait_for_message() + return queue.get_messages_nowait() + + async def __call__(self, scope, receive, send): + """Implements the ASGI protocol. + + See details at: + https://asgi.readthedocs.io/en/latest/specs/index.html. + """ + proxy_request = ASGIProxyRequest(scope=scope, receive=receive, send=send) + async for message in self.proxy_request(proxy_request): + if not isinstance(message, ResponseStatus): + await send(message) + + async def proxy_asgi_receive( + self, receive: Receive, queue: MessageQueue + ) -> Optional[int]: + """Proxies the `receive` interface, placing its messages into the queue. + + Once a disconnect message is received, the call exits and `receive` is no longer + called. + + For HTTP messages, `None` is always returned. + For websocket messages, the disconnect code is returned if a disconnect code is + received. + """ + try: + while True: + msg = await receive() + await queue(msg) + + if msg["type"] == "http.disconnect": + return None + + if msg["type"] == "websocket.disconnect": + return msg["code"] + finally: + # Close the queue so any subsequent calls to fetch messages return + # immediately: https://github.com/ray-project/ray/issues/38368. + queue.close() + + def setup_request_context_and_handle( + self, + app_name: str, + handle: DeploymentHandle, + route: str, + proxy_request: ProxyRequest, + internal_request_id: str, + ) -> Tuple[DeploymentHandle, str]: + """Setup request context and handle for the request. + + Unpack HTTP request headers and extract info to set up request context and + handle. + """ + request_context_info = { + "route": route, + "app_name": app_name, + "_internal_request_id": internal_request_id, + "is_http_request": True, + } + for key, value in proxy_request.headers: + if key.decode() == SERVE_MULTIPLEXED_MODEL_ID: + multiplexed_model_id = value.decode() + handle = handle.options(multiplexed_model_id=multiplexed_model_id) + request_context_info["multiplexed_model_id"] = multiplexed_model_id + if key.decode() == SERVE_HTTP_REQUEST_ID_HEADER: + request_context_info["request_id"] = value.decode() + ray.serve.context._serve_request_context.set( + ray.serve.context._RequestContext(**request_context_info) + ) + return handle, request_context_info["request_id"] + + async def _format_handle_arg_for_java( + self, + proxy_request: ProxyRequest, + ) -> bytes: + """Convert an HTTP request to the Java-accepted format (single byte string).""" + query_string = proxy_request.scope.get("query_string") + http_body_bytes = await receive_http_body( + proxy_request.scope, proxy_request.receive, proxy_request.send + ) + if query_string: + arg = query_string.decode().split("=", 1)[1] + else: + arg = http_body_bytes.decode() + + return arg + + async def send_request_to_replica( + self, + request_id: str, + internal_request_id: str, + handle: DeploymentHandle, + proxy_request: ProxyRequest, + app_is_cross_language: bool = False, + ) -> ResponseGenerator: + """Send the request to the replica and yield its response messages. + + The yielded values will be ASGI messages until the final one, which will be + the status code. + """ + if app_is_cross_language: + handle_arg_bytes = await self._format_handle_arg_for_java(proxy_request) + # Response is returned as raw bytes, convert it to ASGI messages. + result_callback = convert_object_to_asgi_messages + else: + handle_arg_bytes = proxy_request.serialized_replica_arg( + proxy_actor_name=self.self_actor_name, + ) + # Messages are returned as pickled dictionaries. + result_callback = pickle.loads + + # Proxy the receive interface by placing the received messages on a queue. + # The downstream replica must call back into `receive_asgi_messages` on this + # actor to receive the messages. + receive_queue = MessageQueue() + self.asgi_receive_queues[internal_request_id] = receive_queue + proxy_asgi_receive_task = get_or_create_event_loop().create_task( + self.proxy_asgi_receive(proxy_request.receive, receive_queue) + ) + + response_generator = ProxyResponseGenerator( + handle.remote(handle_arg_bytes), + timeout_s=self.request_timeout_s, + disconnected_task=proxy_asgi_receive_task, + result_callback=result_callback, + ) + + status: Optional[ResponseStatus] = None + response_started = False + expecting_trailers = False + try: + async for asgi_message_batch in response_generator: + # See the ASGI spec for message details: + # https://asgi.readthedocs.io/en/latest/specs/www.html. + for asgi_message in asgi_message_batch: + if asgi_message["type"] == "http.response.start": + # HTTP responses begin with exactly one + # "http.response.start" message containing the "status" + # field. Other response types (e.g., WebSockets) may not. + status_code = str(asgi_message["status"]) + status = ResponseStatus( + code=status_code, + is_error=status_code.startswith(("4", "5")), + ) + expecting_trailers = asgi_message.get("trailers", False) + elif asgi_message["type"] == "websocket.accept": + # Websocket code explicitly handles client disconnects, + # so let the ASGI disconnect message propagate instead of + # cancelling the handler. + response_generator.stop_checking_for_disconnect() + elif ( + asgi_message["type"] == "http.response.body" + and not asgi_message.get("more_body", False) + and not expecting_trailers + ): + # If the body is completed and we aren't expecting trailers, the + # response is done so we should stop listening for disconnects. + response_generator.stop_checking_for_disconnect() + elif asgi_message["type"] == "http.response.trailers": + # If we are expecting trailers, the response is only done when + # the trailers message has been sent. + if not asgi_message.get("more_trailers", False): + response_generator.stop_checking_for_disconnect() + elif asgi_message["type"] in [ + "websocket.close", + "websocket.disconnect", + ]: + status_code = str(asgi_message["code"]) + status = ResponseStatus( + code=status_code, + # All status codes are considered errors aside from: + # 1000 (CLOSE_NORMAL), 1001 (CLOSE_GOING_AWAY). + is_error=status_code not in ["1000", "1001"], + ) + response_generator.stop_checking_for_disconnect() + + yield asgi_message + response_started = True + except BaseException as e: + status = get_http_response_status(e, self.request_timeout_s, request_id) + for asgi_message in send_http_response_on_exception( + status, response_started + ): + yield asgi_message + + finally: + # For websocket connection, queue receive task is done when receiving + # disconnect message from client. + receive_client_disconnect_msg = False + if not proxy_asgi_receive_task.done(): + proxy_asgi_receive_task.cancel() + else: + receive_client_disconnect_msg = True + + # If the server disconnects, status_code can be set above from the + # disconnect message. + # If client disconnects, the disconnect code comes from + # a client message via the receive interface. + if status is None and proxy_request.request_type == "websocket": + if receive_client_disconnect_msg: + # The disconnect message is sent from the client. + status = ResponseStatus( + code=str(proxy_asgi_receive_task.result()), + is_error=True, + ) + else: + # The server disconnect without sending a disconnect message + # (otherwise the `status` would be set). + status = ResponseStatus( + code="1000", # [Sihan] is there a better code for this? + is_error=True, + ) + + del self.asgi_receive_queues[internal_request_id] + + # The status code should always be set. + assert status is not None + yield status + + +class ProxyActorInterface(ABC): + """Abstract interface for proxy actors in Ray Serve. + + This interface defines the contract that all proxy actor implementations must follow, + allowing for different proxy backends (Ray HTTP/gRPC proxies, HAProxy, etc.). + """ + + def __init__( + self, + *, + node_id: NodeId, + node_ip_address: str, + logging_config: LoggingConfig, + log_buffer_size: int = RAY_SERVE_REQUEST_PATH_LOG_BUFFER_SIZE, + ): + """Initialize the proxy actor. + + Args: + node_id: ID of the node this proxy is running on + node_ip_address: IP address of the node + logging_config: Logging configuration + log_buffer_size: Size of the log buffer + """ + self._node_id = node_id + self._node_ip_address = node_ip_address + self._logging_config = logging_config + self._log_buffer_size = log_buffer_size + + self._update_logging_config(logging_config) + + @abstractmethod + async def ready(self) -> str: + """Blocks until the proxy is ready to serve requests. + + Returns: + JSON-serialized metadata containing proxy information (worker ID, log file path, etc.) + """ + pass + + @abstractmethod + async def serving(self, wait_for_applications_running: bool = True) -> None: + """Wait for the proxy to be ready to serve requests. + + Args: + wait_for_applications_running: Whether to wait for the applications to be running + + Returns: + None + """ + pass + + @abstractmethod + async def update_draining( + self, draining: bool, _after: Optional[Any] = None + ) -> None: + """Update the draining status of the proxy. + + Args: + draining: Whether the proxy should be draining + _after: Optional ObjectRef for scheduling dependency + """ + pass + + @abstractmethod + async def is_drained(self, _after: Optional[Any] = None) -> bool: + """Check whether the proxy is drained. + + Args: + _after: Optional ObjectRef for scheduling dependency + + Returns: + True if the proxy is drained, False otherwise + """ + pass + + @abstractmethod + async def check_health(self) -> bool: + """Check the health of the proxy. + + Returns: + True if the proxy is healthy, False otherwise + """ + pass + + @abstractmethod + def pong(self) -> str: + """Respond to ping from replicas. + + Returns: + A response string + """ + pass + + @abstractmethod + async def receive_asgi_messages(self, request_metadata: RequestMetadata) -> bytes: + """Handle ASGI messages for HTTP requests. + + Args: + request_metadata: Metadata about the request + + Returns: + Serialized ASGI messages + """ + pass + + # Testing and debugging methods + @abstractmethod + def _get_http_options(self) -> HTTPOptions: + """Get HTTP options used by the proxy.""" + pass + + @abstractmethod + def _get_logging_config(self) -> Optional[str]: + """Get the file path for the logger (for testing purposes).""" + pass + + @abstractmethod + def _dump_ingress_replicas_for_testing(self, route: str) -> Set: + """Get replicas for a route (for testing).""" + pass + + def _update_logging_config(self, logging_config: LoggingConfig): + configure_component_logger( + component_name="proxy", + component_id=self._node_ip_address, + logging_config=logging_config, + buffer_size=self._log_buffer_size, + ) + + +@ray.remote(num_cpus=0) +class ProxyActor(ProxyActorInterface): + def __init__( + self, + http_options: HTTPOptions, + grpc_options: gRPCOptions, + *, + node_id: NodeId, + node_ip_address: str, + logging_config: LoggingConfig, + long_poll_client: Optional[LongPollClient] = None, + ): # noqa: F821 + super().__init__( + node_id=node_id, + node_ip_address=node_ip_address, + logging_config=logging_config, + ) + + self._grpc_options = grpc_options + self._http_options = configure_http_middlewares(http_options) + grpc_enabled = is_grpc_enabled(self._grpc_options) + event_loop = get_or_create_event_loop() + self.long_poll_client = long_poll_client or LongPollClient( + ray.get_actor(SERVE_CONTROLLER_NAME, namespace=SERVE_NAMESPACE), + { + LongPollNamespace.GLOBAL_LOGGING_CONFIG: self._update_logging_config, + LongPollNamespace.ROUTE_TABLE: self._update_routes_in_proxies, + }, + call_in_event_loop=event_loop, + ) + + startup_msg = f"Proxy starting on node {self._node_id} (HTTP port: {self._http_options.port}" + if grpc_enabled: + startup_msg += f", gRPC port: {self._grpc_options.port})." + else: + startup_msg += ")." + logger.info(startup_msg) + logger.debug( + f"Configure Proxy actor {ray.get_runtime_context().get_actor_id()} " + f"logger with logging config: {logging_config}" + ) + + configure_component_memory_profiler( + component_name="proxy", component_id=node_ip_address + ) + if logging_config.encoding == EncodingType.JSON: + # Create logging context for access logs as a performance optimization. + # While logging_utils can automatically add Ray core and Serve access log context, + # we pre-compute it here since context evaluation is expensive and this context + # will be reused for multiple access log entries. + ray_core_logging_context = CoreContextFilter.get_ray_core_logging_context() + # remove task level log keys from ray core logging context, it would be nice + # to have task level log keys here but we are letting those go in favor of + # performance optimization. Also we cannot include task level log keys here because + # they would referance the current task (__init__) and not the task that is logging. + for key in CoreContextFilter.TASK_LEVEL_LOG_KEYS: + ray_core_logging_context.pop(key, None) + access_log_context = { + **ray_core_logging_context, + SERVE_LOG_COMPONENT: "proxy", + SERVE_LOG_COMPONENT_ID: self._node_ip_address, + "log_to_stderr": False, + "skip_context_filter": True, + "serve_access_log": True, + } + else: + access_log_context = { + "log_to_stderr": False, + "skip_context_filter": True, + "serve_access_log": True, + } + + is_head = self._node_id == get_head_node_id() + self.proxy_router = ProxyRouter(get_proxy_handle) + self.http_proxy = HTTPProxy( + node_id=self._node_id, + node_ip_address=self._node_ip_address, + is_head=is_head, + self_actor_name=ray.get_runtime_context().get_actor_name(), + proxy_router=self.proxy_router, + request_timeout_s=self._http_options.request_timeout_s, + access_log_context=access_log_context, + ) + self.grpc_proxy = ( + gRPCProxy( + node_id=self._node_id, + node_ip_address=self._node_ip_address, + is_head=is_head, + proxy_router=self.proxy_router, + request_timeout_s=self._grpc_options.request_timeout_s, + access_log_context=access_log_context, + ) + if grpc_enabled + else None + ) + + # Start a task to initialize the HTTP server. + # The result of this task is checked in the `ready` method. + self._start_http_server_task = event_loop.create_task( + start_asgi_http_server( + self.http_proxy, + self._http_options, + event_loop=event_loop, + enable_so_reuseport=SOCKET_REUSE_PORT_ENABLED, + ) + ) + # A task that runs the HTTP server until it exits (currently runs forever). + # Populated with the result of self._start_http_server_task. + self._running_http_server_task: Optional[asyncio.Task] = None + + # Start a task to initialize the gRPC server. + # The result of this task is checked in the `ready` method. + self._start_grpc_server_task: Optional[asyncio.Task] = None + if grpc_enabled: + self._start_grpc_server_task = event_loop.create_task( + start_grpc_server( + self.grpc_proxy.service_handler_factory, + self._grpc_options, + event_loop=event_loop, + enable_so_reuseport=SOCKET_REUSE_PORT_ENABLED, + ), + ) + # A task that runs the gRPC server until it exits (currently runs forever). + # Populated with the result of self._start_grpc_server_task. + self._running_grpc_server_task: Optional[asyncio.Task] = None + + _configure_gc_options() + + def _update_routes_in_proxies(self, endpoints: Dict[DeploymentID, EndpointInfo]): + self.proxy_router.update_routes(endpoints) + + def _get_logging_config(self) -> Tuple: + """Get the logging configuration (for testing purposes).""" + log_file_path = None + for handler in logger.handlers: + if isinstance(handler, logging.handlers.MemoryHandler): + log_file_path = handler.target.baseFilename + return log_file_path + + def _dump_ingress_replicas_for_testing(self, route: str) -> Set[ReplicaID]: + _, handle, _ = self.http_proxy.proxy_router.match_route(route) + return handle._router._asyncio_router._request_router._replica_id_set + + async def ready(self) -> str: + """Blocks until the proxy HTTP (and optionally gRPC) servers are running. + + Returns JSON-serialized metadata containing the proxy's worker ID and log + file path. + + Raises any exceptions that occur setting up the HTTP or gRPC server. + """ + try: + self._running_http_server_task = await self._start_http_server_task + except Exception as e: + logger.exception("Failed to start proxy HTTP server.") + raise e from None + + try: + if self._start_grpc_server_task is not None: + self._running_grpc_server_task = await self._start_grpc_server_task + except Exception as e: + logger.exception("Failed to start proxy gRPC server.") + raise e from None + + # Return proxy metadata used by the controller. + # NOTE(zcin): We need to convert the metadata to a json string because + # of cross-language scenarios. Java can't deserialize a Python tuple. + return json.dumps( + [ + ray.get_runtime_context().get_worker_id(), + get_component_logger_file_path(), + ] + ) + + async def serving(self, wait_for_applications_running: bool = True) -> None: + """Wait for the proxy to be ready to serve requests.""" + return + + async def update_draining(self, draining: bool, _after: Optional[Any] = None): + """Update the draining status of the HTTP and gRPC proxies. + + Unused `_after` argument is for scheduling: passing an ObjectRef + allows delaying this call until after the `_after` call has returned. + """ + + self.http_proxy.update_draining(draining) + if self.grpc_proxy: + self.grpc_proxy.update_draining(draining) + + async def is_drained(self, _after: Optional[Any] = None): + """Check whether both HTTP and gRPC proxies are drained or not. + + Unused `_after` argument is for scheduling: passing an ObjectRef + allows delaying this call until after the `_after` call has returned. + """ + + return self.http_proxy.is_drained() and ( + self.grpc_proxy is None or self.grpc_proxy.is_drained() + ) + + async def check_health(self) -> bool: + """No-op method to check on the health of the HTTP Proxy. + + Make sure the async event loop is not blocked. + """ + logger.debug("Received health check.", extra={"log_to_stderr": False}) + return True + + def pong(self): + """Called by the replica to initialize its handle to the proxy.""" + pass + + async def receive_asgi_messages(self, request_metadata: RequestMetadata) -> bytes: + """Get ASGI messages for the provided `request_metadata`. + + After the proxy has stopped receiving messages for this `request_metadata`, + this will always return immediately. + + Raises `KeyError` if this request ID is not found. This will happen when the + request is no longer being handled (e.g., the user disconnects). + """ + return pickle.dumps( + await self.http_proxy.receive_asgi_messages(request_metadata) + ) + + def _get_http_options(self) -> HTTPOptions: + """Internal method to get HTTP options used by the proxy.""" + return self._http_options + + +def _configure_gc_options(): + if not RAY_SERVE_ENABLE_PROXY_GC_OPTIMIZATIONS: + return + + # Collect any objects that exist already and exclude them from future GC. + gc.collect(2) + gc.freeze() + + # Tune the GC threshold to run less frequently (default is 700). + gc.set_threshold(RAY_SERVE_PROXY_GC_THRESHOLD) diff --git a/lib/python3.12/site-packages/ray/serve/_private/proxy_request_response.py b/lib/python3.12/site-packages/ray/serve/_private/proxy_request_response.py new file mode 100644 index 0000000000000000000000000000000000000000..922621f6242487bf842da4a2c3109564a4edf8a6 --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/_private/proxy_request_response.py @@ -0,0 +1,199 @@ +import logging +import pickle +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import Any, AsyncIterator, List, Tuple, Union + +import grpc +from starlette.types import Receive, Scope, Send + +from ray.serve._private.common import StreamingHTTPRequest, gRPCRequest +from ray.serve._private.constants import SERVE_LOGGER_NAME +from ray.serve._private.utils import DEFAULT +from ray.serve.grpc_util import RayServegRPCContext + +logger = logging.getLogger(SERVE_LOGGER_NAME) + + +class ProxyRequest(ABC): + """Base ProxyRequest class to use in the common interface among proxies""" + + @property + @abstractmethod + def request_type(self) -> str: + raise NotImplementedError + + @property + @abstractmethod + def method(self) -> str: + raise NotImplementedError + + @property + @abstractmethod + def route_path(self) -> str: + raise NotImplementedError + + @property + @abstractmethod + def is_route_request(self) -> bool: + raise NotImplementedError + + @property + @abstractmethod + def is_health_request(self) -> bool: + raise NotImplementedError + + +class ASGIProxyRequest(ProxyRequest): + """ProxyRequest implementation to wrap ASGI scope, receive, and send.""" + + def __init__(self, scope: Scope, receive: Receive, send: Send): + self.scope = scope + self.receive = receive + self.send = send + + @property + def request_type(self) -> str: + return self.scope.get("type", "") + + @property + def method(self) -> str: + # WebSocket messages don't have a 'method' field. + return self.scope.get("method", "WS").upper() + + @property + def route_path(self) -> str: + return self.scope.get("path", "")[len(self.root_path) :] + + @property + def is_route_request(self) -> bool: + return self.route_path == "/-/routes" + + @property + def is_health_request(self) -> bool: + return self.route_path == "/-/healthz" + + @property + def client(self) -> str: + return self.scope.get("client", "") + + @property + def root_path(self) -> str: + return self.scope.get("root_path", "") + + @property + def path(self) -> str: + return self.scope.get("path", "") + + @property + def headers(self) -> List[Tuple[bytes, bytes]]: + return self.scope.get("headers", []) + + def set_path(self, path: str): + self.scope["path"] = path + + def set_root_path(self, root_path: str): + self.scope["root_path"] = root_path + + def serialized_replica_arg(self, proxy_actor_name: str) -> bytes: + # NOTE(edoakes): it's important that the request is sent as raw bytes to + # skip the Ray cloudpickle serialization codepath for performance. + return pickle.dumps( + StreamingHTTPRequest( + asgi_scope=self.scope, + proxy_actor_name=proxy_actor_name, + ) + ) + + +class gRPCProxyRequest(ProxyRequest): + """ProxyRequest implementation to wrap gRPC request protobuf and metadata.""" + + def __init__( + self, + request_proto: Any, + context: grpc._cython.cygrpc._ServicerContext, + service_method: str, + stream: bool, + ): + self._request_proto = request_proto + self.context = context + self.service_method = service_method + self.stream = stream + self.app_name = "" + self.request_id = None + self.method_name = "__call__" + self.multiplexed_model_id = DEFAULT.VALUE + # ray_serve_grpc_context is a class implemented by us to be able to serialize + # the object and pass it into the deployment. + self.ray_serve_grpc_context = RayServegRPCContext(context) + self.setup_variables() + + def setup_variables(self): + if not self.is_route_request and not self.is_health_request: + service_method_split = self.service_method.split("/") + self.method_name = service_method_split[-1] + for key, value in self.context.invocation_metadata(): + if key == "application": + self.app_name = value + elif key == "request_id": + self.request_id = value + elif key == "multiplexed_model_id": + self.multiplexed_model_id = value + + @property + def request_type(self) -> str: + return "grpc" + + @property + def method(self) -> str: + return self.service_method + + @property + def route_path(self) -> str: + return self.app_name + + @property + def is_route_request(self) -> bool: + return self.service_method == "/ray.serve.RayServeAPIService/ListApplications" + + @property + def is_health_request(self) -> bool: + return self.service_method == "/ray.serve.RayServeAPIService/Healthz" + + def send_request_id(self, request_id: str): + # Setting the trailing metadata on the ray_serve_grpc_context object, so it's + # not overriding the ones set from the user and will be sent back to the + # client altogether. + self.ray_serve_grpc_context.set_trailing_metadata([("request_id", request_id)]) + + def serialized_replica_arg(self) -> bytes: + # NOTE(edoakes): it's important that the request is sent as raw bytes to + # skip the Ray cloudpickle serialization codepath for performance. + return pickle.dumps(gRPCRequest(user_request_proto=self._request_proto)) + + +@dataclass(frozen=True) +class ResponseStatus: + code: Union[str, grpc.StatusCode] # Must be convertible to a string. + is_error: bool = False + message: str = "" + + +# Yields protocol-specific messages followed by a final `ResponseStatus`. +ResponseGenerator = AsyncIterator[Union[Any, ResponseStatus]] + + +@dataclass(frozen=True) +class HandlerMetadata: + application_name: str = "" + deployment_name: str = "" + route: str = "" + + +@dataclass(frozen=True) +class ResponseHandlerInfo: + response_generator: ResponseGenerator + metadata: HandlerMetadata + should_record_access_log: bool + should_increment_ongoing_requests: bool diff --git a/lib/python3.12/site-packages/ray/serve/_private/proxy_response_generator.py b/lib/python3.12/site-packages/ray/serve/_private/proxy_response_generator.py new file mode 100644 index 0000000000000000000000000000000000000000..59f31849d4a6e317997a0213736b82762d4f9060 --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/_private/proxy_response_generator.py @@ -0,0 +1,181 @@ +import asyncio +import logging +import time +from abc import ABC, abstractmethod +from asyncio.tasks import FIRST_COMPLETED +from typing import Any, Callable, Optional, Union + +from ray.serve._private.constants import SERVE_LOGGER_NAME +from ray.serve._private.utils import calculate_remaining_timeout +from ray.serve.exceptions import RequestCancelledError +from ray.serve.handle import DeploymentResponse, DeploymentResponseGenerator + +logger = logging.getLogger(SERVE_LOGGER_NAME) + + +class _ProxyResponseGeneratorBase(ABC): + def __init__( + self, + *, + timeout_s: Optional[float] = None, + disconnected_task: Optional[asyncio.Task] = None, + result_callback: Optional[Callable[[Any], Any]] = None, + ): + """Implements a generator wrapping a deployment response. + + Args: + - timeout_s: an end-to-end timeout for the request. If this expires and the + response is not completed, the request will be cancelled. If `None`, + there's no timeout. + - disconnected_task: a task whose completion signals that the client has + disconnected. When this happens, the request will be cancelled. If `None`, + disconnects will not be detected. + - result_callback: will be called on each result before it's returned. If + `None`, the unmodified result is returned. + """ + self._timeout_s = timeout_s + self._start_time_s = time.time() + self._disconnected_task = disconnected_task + self._result_callback = result_callback + + def __aiter__(self): + return self + + @abstractmethod + async def __anext__(self): + """Return the next message in the stream. + + Raises: + TimeoutError: On timeout. + asyncio.CancelledError: On disconnect. + StopAsyncIteration: When the stream is completed. + """ + pass + + def stop_checking_for_disconnect(self): + """Once this is called, the disconnected_task will be ignored.""" + self._disconnected_task = None + + +def swallow_cancelled(task: asyncio.Task): + try: + task.result() + except (RequestCancelledError, asyncio.CancelledError): + # We expect RequestCancelledError to be raised because for disconnect or + # timeouts, we explicitly call resp.cancel(). To avoid "Task exception + # was never retrieved" errors from spamming the proxy logs, swallow + # them here. + pass + except Exception: + # For all other exceptions, do not catch and instead re-raise here so that + # they will be logged properly. + raise + + +class ProxyResponseGenerator(_ProxyResponseGeneratorBase): + """Wraps a unary DeploymentResponse or streaming DeploymentResponseGenerator. + + In the case of a unary DeploymentResponse, __anext__ will only ever return one + result. + """ + + def __init__( + self, + response: Union[DeploymentResponse, DeploymentResponseGenerator], + *, + timeout_s: Optional[float] = None, + disconnected_task: Optional[asyncio.Task] = None, + result_callback: Optional[Callable[[Any], Any]] = None, + ): + super().__init__( + timeout_s=timeout_s, + disconnected_task=disconnected_task, + result_callback=result_callback, + ) + self._done = False + self._response = response + + def cancelled(self) -> bool: + return self._response.cancelled() + + async def __anext__(self): + if self._done: + raise StopAsyncIteration + + try: + if isinstance(self._response, DeploymentResponseGenerator): + result = await self._get_next_streaming_result() + else: + result = await self._get_unary_result() + self._done = True + + if self._result_callback is not None: + result = self._result_callback(result) + except asyncio.CancelledError as e: + # This is specifically for gRPC. The cancellation can happen from client + # dropped connection before the request is completed. If self._response is + # not already cancelled, we want to explicitly cancel the task, so it + # doesn't waste cluster resource in this case and can be terminated + # gracefully. + if not self._response.cancelled(): + self._response.cancel() + self._done = True + + raise e from None + except Exception as e: + self._done = True + raise e from None + + return result + + async def _await_response_anext(self) -> Any: + return await self._response.__anext__() + + async def _get_next_streaming_result(self) -> Any: + next_result_task = asyncio.create_task(self._await_response_anext()) + tasks = [next_result_task] + if self._disconnected_task is not None: + tasks.append(self._disconnected_task) + + done, _ = await asyncio.wait( + tasks, + return_when=FIRST_COMPLETED, + timeout=calculate_remaining_timeout( + timeout_s=self._timeout_s, + start_time_s=self._start_time_s, + curr_time_s=time.time(), + ), + ) + if next_result_task in done: + return next_result_task.result() + elif self._disconnected_task in done: + next_result_task.cancel() + next_result_task.add_done_callback(swallow_cancelled) + self._response.cancel() + raise asyncio.CancelledError() + else: + next_result_task.cancel() + next_result_task.add_done_callback(swallow_cancelled) + self._response.cancel() + raise TimeoutError() + + async def _await_response(self) -> Any: + return await self._response + + async def _get_unary_result(self) -> Any: + result_task = asyncio.create_task(self._await_response()) + tasks = [result_task] + if self._disconnected_task is not None: + tasks.append(self._disconnected_task) + + done, _ = await asyncio.wait( + tasks, return_when=FIRST_COMPLETED, timeout=self._timeout_s + ) + if result_task in done: + return result_task.result() + elif self._disconnected_task in done: + self._response.cancel() + raise asyncio.CancelledError() + else: + self._response.cancel() + raise TimeoutError() diff --git a/lib/python3.12/site-packages/ray/serve/_private/proxy_router.py b/lib/python3.12/site-packages/ray/serve/_private/proxy_router.py new file mode 100644 index 0000000000000000000000000000000000000000..3c70d53192cf51bb1634a1f560da4a1638bfb657 --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/_private/proxy_router.py @@ -0,0 +1,245 @@ +import logging +from typing import Any, Callable, Dict, List, Optional, Tuple + +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.routing import Route +from starlette.types import Scope + +from ray.serve._private.common import ApplicationName, DeploymentID, EndpointInfo +from ray.serve._private.constants import SERVE_LOGGER_NAME +from ray.serve._private.thirdparty.get_asgi_route_name import get_asgi_route_name +from ray.serve.handle import DeploymentHandle + +logger = logging.getLogger(SERVE_LOGGER_NAME) + +NO_ROUTES_MESSAGE = "Route table is not populated yet." +NO_REPLICAS_MESSAGE = "No replicas are available yet." + + +class ProxyRouter: + """Router interface for the proxy to use.""" + + def __init__( + self, + get_handle: Callable[[str, str], DeploymentHandle], + ): + # Function to get a handle given a name. Used to mock for testing. + self._get_handle = get_handle + # Contains a ServeHandle for each endpoint. + self.handles: Dict[DeploymentID, DeploymentHandle] = dict() + # Flipped to `True` once the route table has been updated at least once. + # The proxy router is not ready for traffic until the route table is populated + self._route_table_populated = False + + # Info used for HTTP proxy + # Routes sorted in order of decreasing length. + self.sorted_routes: List[str] = list() + # Endpoints associated with the routes. + self.route_info: Dict[str, DeploymentID] = dict() + # Map of application name to is_cross_language. + self.app_to_is_cross_language: Dict[ApplicationName, bool] = dict() + + # Info used for gRPC proxy + # Endpoints info associated with endpoints. + self.endpoints: Dict[DeploymentID, EndpointInfo] = dict() + + # Map of route prefix to list of route patterns for that endpoint + # Used to match incoming requests to ASGI route patterns for metrics + self.route_patterns: Dict[str, List[str]] = dict() + # Cache of mock Starlette apps for route pattern matching + # Key: route prefix, Value: pre-built Starlette app with routes + self._route_pattern_apps: Dict[str, Any] = dict() + + def ready_for_traffic(self, is_head: bool) -> Tuple[bool, str]: + """Whether the proxy router is ready to serve traffic. + + The first return value will be false if any of the following hold: + - The route table has not been populated yet with a non-empty set of routes + - The route table has been populated, but none of the handles + have received running replicas yet AND it lives on a worker node. + + Otherwise, the first return value will be true. + """ + + if not self._route_table_populated: + return False, NO_ROUTES_MESSAGE + + # NOTE(zcin): For the proxy on the head node, even if none of its handles have + # been populated with running replicas yet, we MUST mark the proxy as ready for + # traffic. This is to handle the case when all deployments have scaled to zero. + # If the deployments (more precisely, ingress deployments) have all scaled down + # to zero, at least one proxy needs to be able to receive incoming requests to + # trigger upscale. + if is_head: + return True, "" + + for handle in self.handles.values(): + if handle.running_replicas_populated(): + return True, "" + + return False, NO_REPLICAS_MESSAGE + + def update_routes(self, endpoints: Dict[DeploymentID, EndpointInfo]): + logger.info( + f"Got updated endpoints: {endpoints}.", extra={"log_to_stderr": True} + ) + if endpoints: + self._route_table_populated = True + + self.endpoints = endpoints + + existing_handles = set(self.handles.keys()) + routes = [] + route_info = {} + app_to_is_cross_language = {} + route_patterns = {} + for endpoint, info in endpoints.items(): + routes.append(info.route) + route_info[info.route] = endpoint + app_to_is_cross_language[endpoint.app_name] = info.app_is_cross_language + if info.route_patterns: + route_patterns[info.route] = info.route_patterns + if endpoint in self.handles: + existing_handles.remove(endpoint) + else: + self.handles[endpoint] = self._get_handle(endpoint, info) + + # Clean up any handles that are no longer used. + if len(existing_handles) > 0: + logger.info( + f"Deleting {len(existing_handles)} unused handles.", + extra={"log_to_stderr": False}, + ) + for endpoint in existing_handles: + del self.handles[endpoint] + + # Routes are sorted in order of decreasing length to enable longest + # prefix matching. + self.sorted_routes = sorted(routes, key=lambda x: len(x), reverse=True) + self.route_info = route_info + self.app_to_is_cross_language = app_to_is_cross_language + self.route_patterns = route_patterns + # Invalidate cached mock apps when route patterns change + self._route_pattern_apps.clear() + + def match_route( + self, target_route: str + ) -> Optional[Tuple[str, DeploymentHandle, bool]]: + """Return the longest prefix match among existing routes for the route. + Args: + target_route: route to match against. + Returns: + (route, handle, is_cross_language) if found, else None. + """ + + for route in self.sorted_routes: + if target_route.startswith(route): + matched = False + # If the route we matched on ends in a '/', then so does the + # target route and this must be a match. + if route.endswith("/"): + matched = True + # If the route we matched on doesn't end in a '/', we need to + # do another check to ensure that either this is an exact match + # or the next character in the target route is a '/'. This is + # to guard against the scenario where we have '/route' as a + # prefix and there's a request to '/routesuffix'. In this case, + # it should *not* be a match. + elif len(target_route) == len(route) or target_route[len(route)] == "/": + matched = True + + if matched: + endpoint = self.route_info[route] + return ( + route, + self.handles[endpoint], + self.app_to_is_cross_language[endpoint.app_name], + ) + + return None + + def get_handle_for_endpoint( + self, target_app_name: str + ) -> Optional[Tuple[str, DeploymentHandle, bool]]: + """Return the handle that matches with endpoint. + + Args: + target_app_name: app_name to match against. + Returns: + (route, handle, is_cross_language) for the single app if there + is only one, else find the app and handle for exact match. Else return None. + """ + for endpoint_tag, handle in self.handles.items(): + # If the target_app_name matches with the endpoint or if + # there is only one endpoint. + if target_app_name == endpoint_tag.app_name or len(self.handles) == 1: + endpoint_info = self.endpoints[endpoint_tag] + return ( + endpoint_info.route, + handle, + endpoint_info.app_is_cross_language, + ) + + return None + + def match_route_pattern(self, route_prefix: str, asgi_scope: Scope) -> str: + """Match an incoming request to a specific route pattern. + + This attempts to match the request path to a route pattern (e.g., /api/{user_id}) + rather than just the route prefix. This provides more granular metrics. + + The mock Starlette app is cached per route_prefix for performance, avoiding + the overhead of recreating the app and routes on every request. + + Args: + route_prefix: The matched route prefix from match_route() + asgi_scope: The ASGI scope containing the request path and method + + Returns: + The matched route pattern if available, otherwise the route_prefix + """ + # If we don't have route patterns for this prefix, return the prefix + if route_prefix not in self.route_patterns: + return route_prefix + + patterns = self.route_patterns[route_prefix] + if not patterns: + return route_prefix + + # Get or create the cached mock app for this route_prefix + mock_app = self._route_pattern_apps.get(route_prefix) + if mock_app is None: + try: + # Create routes from patterns + # We use a dummy endpoint since we only need pattern matching + async def dummy_endpoint(request: Request): + pass + + routes = [Route(pattern, dummy_endpoint) for pattern in patterns] + mock_app = Starlette(routes=routes) + + # Cache the mock app for future requests + self._route_pattern_apps[route_prefix] = mock_app + except Exception: + # If app creation fails, fall back to route prefix + logger.debug( + f"Failed to create mock app for route pattern matching: {route_prefix}", + exc_info=True, + ) + return route_prefix + + # Use the cached mock app to match the route pattern + try: + matched = get_asgi_route_name(mock_app, asgi_scope) + if matched: + return matched + except Exception: + # If matching fails for any reason, fall back to route prefix + logger.debug( + f"Failed to match route pattern for {route_prefix}", + exc_info=True, + ) + + # Fall back to route prefix if no pattern matched + return route_prefix diff --git a/lib/python3.12/site-packages/ray/serve/_private/proxy_state.py b/lib/python3.12/site-packages/ray/serve/_private/proxy_state.py new file mode 100644 index 0000000000000000000000000000000000000000..32f462a157f1752ec842de94709135706c3c6e39 --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/_private/proxy_state.py @@ -0,0 +1,824 @@ +import asyncio +import json +import logging +import os +from abc import ABC, abstractmethod +from copy import deepcopy +from typing import Dict, List, Optional, Set, Tuple, Type + +import ray +from ray import ObjectRef +from ray._common.network_utils import build_address +from ray.actor import ActorHandle +from ray.exceptions import GetTimeoutError, RayActorError +from ray.serve._private.cluster_node_info_cache import ClusterNodeInfoCache +from ray.serve._private.common import NodeId, RequestProtocol +from ray.serve._private.constants import ( + ASYNC_CONCURRENCY, + PROXY_DRAIN_CHECK_PERIOD_S, + PROXY_HEALTH_CHECK_PERIOD_S, + PROXY_HEALTH_CHECK_TIMEOUT_S, + PROXY_HEALTH_CHECK_UNHEALTHY_THRESHOLD, + PROXY_READY_CHECK_TIMEOUT_S, + RAY_SERVE_ALWAYS_RUN_PROXY_ON_HEAD_NODE, + RAY_SERVE_ENABLE_TASK_EVENTS, + SERVE_LOGGER_NAME, + SERVE_NAMESPACE, + SERVE_PROXY_NAME, +) +from ray.serve._private.proxy import ProxyActor +from ray.serve._private.utils import ( + Timer, + TimerBase, + format_actor_name, + is_grpc_enabled, +) +from ray.serve.config import DeploymentMode, HTTPOptions, gRPCOptions +from ray.serve.schema import ( + LoggingConfig, + ProxyDetails, + ProxyStatus, + Target, +) +from ray.util.scheduling_strategies import NodeAffinitySchedulingStrategy + +logger = logging.getLogger(SERVE_LOGGER_NAME) + + +class ProxyWrapper(ABC): + @property + @abstractmethod + def actor_id(self) -> str: + """Return the actor id of the proxy actor.""" + raise NotImplementedError + + @abstractmethod + def is_ready(self, timeout_s: float) -> Optional[bool]: + """Return whether proxy is ready to be serving requests. + + Since actual readiness check is asynchronous, this method could return + any of the following statuses: + - None: Readiness check is pending + - True: Readiness check completed successfully (proxy is ready) + - False: Readiness check completed with failure (either timing out + or failing) + """ + raise NotImplementedError + + @abstractmethod + def is_healthy(self, timeout_s: float) -> Optional[bool]: + """Return whether the proxy actor is healthy. + + Since actual health-check is asynchronous, this method could return + either of the following statuses: + - None: Health-check is pending + - True: Health-check completed successfully (proxy is healthy) + - False: Health-check completed with failure (either timing out or failing) + """ + raise NotImplementedError + + @abstractmethod + def is_drained(self, timeout_s: float) -> Optional[bool]: + """Return whether the proxy actor is drained. + + Since actual check whether proxy is drained is asynchronous, this method could + return either of the following statuses: + - None: Drain-check is pending + - True: Drain-check completed, node *is drained* + - False: Drain-check completed, node is *NOT* drained + """ + raise NotImplementedError + + @abstractmethod + def is_shutdown(self): + """Return whether the proxy actor is shutdown.""" + raise NotImplementedError + + @abstractmethod + def update_draining(self, draining: bool): + """Update the draining status of the proxy actor.""" + raise NotImplementedError + + @abstractmethod + def kill(self): + """Kill the proxy actor.""" + raise NotImplementedError + + +class ActorProxyWrapper(ProxyWrapper): + def __init__( + self, + logging_config: LoggingConfig, + actor_handle: Optional[ActorHandle] = None, + http_options: Optional[HTTPOptions] = None, + grpc_options: Optional[gRPCOptions] = None, + name: Optional[str] = None, + node_id: Optional[str] = None, + node_ip_address: Optional[str] = None, + port: Optional[int] = None, + proxy_actor_class: Type[ProxyActor] = ProxyActor, + ): + # initialize with provided proxy actor handle or get or create a new one. + self._actor_handle = actor_handle or self._get_or_create_proxy_actor( + http_options=http_options, + grpc_options=grpc_options, + name=name, + node_id=node_id, + node_ip_address=node_ip_address, + port=port, + proxy_actor_class=proxy_actor_class, + logging_config=logging_config, + ) + self._ready_check_future = None + self._health_check_future = None + self._drained_check_future = None + + self._update_draining_obj_ref = None + + self._node_id = node_id + + self.worker_id = None + self.log_file_path = None + + @staticmethod + def _get_or_create_proxy_actor( + http_options: HTTPOptions, + grpc_options: gRPCOptions, + name: str, + node_id: str, + node_ip_address: str, + port: int, + logging_config: LoggingConfig, + proxy_actor_class: Type[ProxyActor] = ProxyActor, + ) -> ProxyWrapper: + """Helper to start or reuse existing proxy. + + Takes the name of the proxy, the node id, and the node ip address, and look up + or creates a new ProxyActor actor handle for the proxy. + """ + proxy = None + try: + proxy = ray.get_actor(name, namespace=SERVE_NAMESPACE) + except ValueError: + addr = build_address(http_options.host, http_options.port) + logger.info( + f"Starting proxy on node '{node_id}' listening on '{addr}'.", + extra={"log_to_stderr": False}, + ) + + return proxy or proxy_actor_class.options( + num_cpus=http_options.num_cpus, + name=name, + namespace=SERVE_NAMESPACE, + lifetime="detached", + max_concurrency=ASYNC_CONCURRENCY, + max_restarts=0, + scheduling_strategy=NodeAffinitySchedulingStrategy(node_id, soft=False), + enable_task_events=RAY_SERVE_ENABLE_TASK_EVENTS, + ).remote( + http_options, + grpc_options=grpc_options, + node_id=node_id, + node_ip_address=node_ip_address, + logging_config=logging_config, + ) + + @property + def actor_id(self) -> str: + """Return the actor id of the proxy actor.""" + return self._actor_handle._actor_id.hex() + + @property + def actor_handle(self) -> ActorHandle: + """Return the actor handle of the proxy actor. + + This is used in _start_controller() in _private/controller.py to check whether + the proxies exist. It is also used in some tests to access proxy's actor handle. + """ + return self._actor_handle + + def is_ready(self, timeout_s: float) -> Optional[bool]: + if self._ready_check_future is None: + self._ready_check_future = wrap_as_future( + self._actor_handle.ready.remote(), timeout_s=timeout_s + ) + + if not self._ready_check_future.done(): + return None + + try: + worker_id, log_file_path = json.loads(self._ready_check_future.result()) + self.worker_id = worker_id + self.log_file_path = log_file_path + return True + except TimeoutError: + logger.warning( + f"Proxy actor readiness check for proxy on {self._node_id}" + f" didn't complete in {timeout_s}s." + ) + except Exception: + logger.exception( + f"Unexpected error invoking readiness check for proxy" + f" on {self._node_id}", + ) + finally: + self._ready_check_future = None + + return False + + def is_healthy(self, timeout_s: float) -> Optional[bool]: + if self._health_check_future is None: + self._health_check_future = wrap_as_future( + self._actor_handle.check_health.remote(), timeout_s=timeout_s + ) + + if not self._health_check_future.done(): + return None + + try: + return self._health_check_future.result() + except TimeoutError: + logger.warning( + f"Didn't receive health check response for proxy" + f" on {self._node_id} after {timeout_s}s." + ) + except Exception: + logger.exception( + f"Unexpected error invoking health check for proxy " + f"on {self._node_id}", + ) + finally: + self._health_check_future = None + + return False + + def is_drained(self, timeout_s: float) -> Optional[bool]: + if self._drained_check_future is None: + self._drained_check_future = wrap_as_future( + self._actor_handle.is_drained.remote(), + timeout_s=timeout_s, + ) + + if not self._drained_check_future.done(): + return None + + try: + is_drained = self._drained_check_future.result() + return is_drained + except TimeoutError: + logger.warning( + f"Didn't receive drain check response for proxy" + f" on {self._node_id} after {timeout_s}s." + ) + except Exception: + logger.exception( + f"Unexpected error invoking drain-check for proxy " + f"on {self._node_id}", + ) + finally: + self._drained_check_future = None + + return False + + def is_shutdown(self) -> bool: + """Return whether the proxy actor is shutdown. + + If the actor is dead, the health check will return RayActorError. + """ + try: + ray.get(self._actor_handle.check_health.remote(), timeout=0) + except RayActorError: + # The actor is dead, so it's ready for shutdown. + return True + except GetTimeoutError: + pass + + # The actor is still alive, so it's not ready for shutdown. + return False + + def update_draining(self, draining: bool): + """Update the draining status of the proxy actor.""" + # NOTE: All update_draining calls are implicitly serialized, by specifying + # `ObjectRef` of the previous call + self._update_draining_obj_ref = self._actor_handle.update_draining.remote( + draining, _after=self._update_draining_obj_ref + ) + # In case of cancelled draining, make sure pending draining check is cancelled + # as well + if not draining: + future = self._drained_check_future + self._drained_check_future = None + if future: + future.cancel() + + def kill(self): + """Kill the proxy actor.""" + ray.kill(self._actor_handle, no_restart=True) + + +class ProxyState: + def __init__( + self, + actor_proxy_wrapper: ProxyWrapper, + actor_name: str, + node_id: str, + node_ip: str, + node_instance_id: str, + proxy_restart_count: int = 0, + timer: TimerBase = Timer(), + ): + self._actor_proxy_wrapper = actor_proxy_wrapper + self._actor_name = actor_name + self._node_id = node_id + self._status = ProxyStatus.STARTING + self._timer = timer + self._shutting_down = False + self._consecutive_health_check_failures: int = 0 + self._proxy_restart_count = proxy_restart_count + self._last_health_check_time: Optional[float] = None + self._last_drain_check_time: Optional[float] = None + + self._actor_details = ProxyDetails( + node_id=node_id, + node_ip=node_ip, + node_instance_id=node_instance_id, + actor_id=self._actor_proxy_wrapper.actor_id, + actor_name=self._actor_name, + status=self._status, + ) + + @property + def actor_handle(self) -> ActorHandle: + return self._actor_proxy_wrapper.actor_handle + + @property + def actor_name(self) -> str: + return self._actor_name + + @property + def actor_id(self) -> str: + return self._actor_proxy_wrapper.actor_id + + @property + def status(self) -> ProxyStatus: + return self._status + + @property + def actor_details(self) -> ProxyDetails: + return self._actor_details + + @property + def proxy_restart_count(self) -> int: + return self._proxy_restart_count + + def _set_status(self, status: ProxyStatus) -> None: + """Sets _status and updates _actor_details with the new status. + + NOTE: This method should not be used directly, instead please + use `try_update_status` method + """ + self._status = status + self.update_actor_details(status=self._status) + + def try_update_status(self, status: ProxyStatus): + """Try update with the new status and only update when the conditions are met. + + Status will only set to UNHEALTHY after PROXY_HEALTH_CHECK_UNHEALTHY_THRESHOLD + consecutive failures. A warning will be logged when the status is set to + UNHEALTHY. Also, when status is set to HEALTHY, we will reset + self._consecutive_health_check_failures to 0. + """ + + if status == ProxyStatus.UNHEALTHY: + self._consecutive_health_check_failures += 1 + # Early return to skip setting UNHEALTHY status if there are still room for + # retry. + if ( + self._consecutive_health_check_failures + < PROXY_HEALTH_CHECK_UNHEALTHY_THRESHOLD + ): + return + else: + # If all retries have been exhausted and setting the status to + # UNHEALTHY, log a warning message to the user. + logger.warning( + f"Proxy {self._actor_name} failed the health check " + f"{self._consecutive_health_check_failures} times in a row, marking" + f" it unhealthy." + ) + else: + # Reset self._consecutive_health_check_failures when status is not + # UNHEALTHY + self._consecutive_health_check_failures = 0 + + self._set_status(status=status) + + def update_actor_details(self, **kwargs) -> None: + """Updates _actor_details with passed in kwargs.""" + details_kwargs = self._actor_details.dict() + details_kwargs.update(kwargs) + self._actor_details = ProxyDetails(**details_kwargs) + + def reconcile(self, draining: bool = False): + try: + self._reconcile_internal(draining) + except Exception as e: + self.try_update_status(ProxyStatus.UNHEALTHY) + logger.error( + "Unexpected error occurred when reconciling stae of " + f"proxy on node {self._node_id}", + exc_info=e, + ) + + def _reconcile_internal(self, draining: bool): + """Update the status of the current proxy. + + The state machine is: + STARTING -> HEALTHY or UNHEALTHY + HEALTHY -> DRAINING or UNHEALTHY + DRAINING -> HEALTHY or UNHEALTHY or DRAINED + + UNHEALTHY is a terminal state upon reaching which, Proxy is going to be + restarted by the controller + """ + if ( + self._shutting_down + or self._status == ProxyStatus.DRAINED + or self._status == ProxyStatus.UNHEALTHY + ): + return + + # Doing a linear backoff for the ready check timeout. + ready_check_timeout = ( + self.proxy_restart_count + 1 + ) * PROXY_READY_CHECK_TIMEOUT_S + + if self._status == ProxyStatus.STARTING: + is_ready_response = self._actor_proxy_wrapper.is_ready(ready_check_timeout) + if is_ready_response is not None: + if is_ready_response: + self.try_update_status(ProxyStatus.HEALTHY) + self.update_actor_details( + worker_id=self._actor_proxy_wrapper.worker_id, + log_file_path=self._actor_proxy_wrapper.log_file_path, + status=self._status, + ) + else: + self.try_update_status(ProxyStatus.UNHEALTHY) + logger.warning( + f"Proxy actor reported not ready on node {self._node_id}" + ) + else: + # At this point, the proxy is either in HEALTHY or DRAINING status. + assert self._status in {ProxyStatus.HEALTHY, ProxyStatus.DRAINING} + + should_check_health = self._last_health_check_time is None or ( + self._timer.time() - self._last_health_check_time + >= PROXY_HEALTH_CHECK_PERIOD_S + ) + # Perform health-check for proxy's actor (if necessary) + if should_check_health: + is_healthy_response = self._actor_proxy_wrapper.is_healthy( + PROXY_HEALTH_CHECK_TIMEOUT_S + ) + if is_healthy_response is not None: + if is_healthy_response: + # At this stage status is either HEALTHY or DRAINING, and here + # we simply reset the status + self.try_update_status(self._status) + else: + self.try_update_status(ProxyStatus.UNHEALTHY) + + self._last_health_check_time = self._timer.time() + + # Handle state transitions (if necessary) + if self._status == ProxyStatus.UNHEALTHY: + return + elif self._status == ProxyStatus.HEALTHY: + if draining: + logger.info(f"Draining proxy on node '{self._node_id}'.") + assert self._last_drain_check_time is None + + self._actor_proxy_wrapper.update_draining(draining=True) + self.try_update_status(ProxyStatus.DRAINING) + elif self._status == ProxyStatus.DRAINING: + if not draining: + logger.info(f"No longer draining proxy on node '{self._node_id}'.") + self._last_drain_check_time = None + + self._actor_proxy_wrapper.update_draining(draining=False) + self.try_update_status(ProxyStatus.HEALTHY) + else: + should_check_drain = self._last_drain_check_time is None or ( + self._timer.time() - self._last_drain_check_time + >= PROXY_DRAIN_CHECK_PERIOD_S + ) + if should_check_drain: + # NOTE: We use the same timeout as for readiness checking + is_drained_response = self._actor_proxy_wrapper.is_drained( + PROXY_READY_CHECK_TIMEOUT_S + ) + if is_drained_response is not None: + if is_drained_response: + self.try_update_status(ProxyStatus.DRAINED) + + self._last_drain_check_time = self._timer.time() + + def shutdown(self): + self._shutting_down = True + self._actor_proxy_wrapper.kill() + + def is_ready_for_shutdown(self) -> bool: + """Return whether the proxy actor is shutdown. + + For a proxy actor to be considered shutdown, it must be marked as + _shutting_down and the actor must be shut down. + """ + if not self._shutting_down: + return False + + return self._actor_proxy_wrapper.is_shutdown() + + +class ProxyStateManager: + """Manages all state for proxies in the system. + + This class is *not* thread safe, so any state-modifying methods should be + called with a lock held. + """ + + def __init__( + self, + http_options: HTTPOptions, + head_node_id: str, + cluster_node_info_cache: ClusterNodeInfoCache, + logging_config: LoggingConfig, + grpc_options: Optional[gRPCOptions] = None, + proxy_actor_class: Type[ProxyActor] = ProxyActor, + actor_proxy_wrapper_class: Type[ProxyWrapper] = ActorProxyWrapper, + timer: TimerBase = Timer(), + ): + self.logging_config = logging_config + self._http_options = http_options or HTTPOptions() + self._grpc_options = grpc_options or gRPCOptions() + self._proxy_states: Dict[NodeId, ProxyState] = dict() + self._proxy_restart_counts: Dict[NodeId, int] = dict() + self._head_node_id: str = head_node_id + self._proxy_actor_class = proxy_actor_class + self._actor_proxy_wrapper_class = actor_proxy_wrapper_class + self._timer = timer + + self._cluster_node_info_cache = cluster_node_info_cache + + assert isinstance(head_node_id, str) + + def reconfigure_logging_config(self, logging_config: LoggingConfig): + self.logging_config = logging_config + + def shutdown(self) -> None: + for proxy_state in self._proxy_states.values(): + proxy_state.shutdown() + + def is_ready_for_shutdown(self) -> bool: + """Return whether all proxies are shutdown. + + Iterate through all proxy states and check if all their proxy actors + are shutdown. + """ + return all( + proxy_state.is_ready_for_shutdown() + for proxy_state in self._proxy_states.values() + ) + + def get_config(self) -> HTTPOptions: + return self._http_options + + def get_grpc_config(self) -> gRPCOptions: + return self._grpc_options + + def get_proxy_handles(self) -> Dict[NodeId, ActorHandle]: + return { + node_id: state.actor_handle for node_id, state in self._proxy_states.items() + } + + def get_proxy_names(self) -> Dict[NodeId, str]: + return { + node_id: state.actor_name for node_id, state in self._proxy_states.items() + } + + def get_proxy_details(self) -> Dict[NodeId, ProxyDetails]: + return { + node_id: state.actor_details + for node_id, state in self._proxy_states.items() + } + + def get_targets(self, protocol: RequestProtocol) -> List[Target]: + """In Ray Serve, every proxy is responsible for routing requests to the + correct application. Here we curate a list of targets for the given protocol. + Where each target represents how to reach a proxy. + + Args: + protocol: Either "http" or "grpc" + """ + targets = [] + if protocol == RequestProtocol.HTTP: + port = self._http_options.port + elif protocol == RequestProtocol.GRPC: + if not is_grpc_enabled(self._grpc_options): + return [] + port = self._grpc_options.port + else: + raise ValueError(f"Invalid protocol: {protocol}") + + targets = [ + Target( + ip=state.actor_details.node_ip, + port=port, + instance_id=state.actor_details.node_instance_id, + name=state.actor_name, + ) + for _, state in self._proxy_states.items() + if state.actor_details.status == ProxyStatus.HEALTHY + ] + return targets + + def get_alive_proxy_actor_ids(self) -> Set[str]: + return {state.actor_id for state in self._proxy_states.values()} + + def update(self, proxy_nodes: Set[NodeId] = None) -> Set[str]: + """Update the state of all proxies. + + Start proxies on all nodes if not already exist and stop the proxies on nodes + that are no longer exist. Update all proxy states. Kill and restart + unhealthy proxies. + """ + if proxy_nodes is None: + proxy_nodes = set() + + # Ensure head node always has a proxy (unless FF'd off). + if RAY_SERVE_ALWAYS_RUN_PROXY_ON_HEAD_NODE: + proxy_nodes.add(self._head_node_id) + + target_nodes = self._get_target_nodes(proxy_nodes) + target_node_ids = {node_id for node_id, _, _ in target_nodes} + + for node_id, proxy_state in self._proxy_states.items(): + draining = node_id not in target_node_ids + proxy_state.reconcile(draining) + + self._stop_proxies_if_needed() + self._start_proxies_if_needed(target_nodes) + + def _get_target_nodes(self, proxy_nodes) -> List[Tuple[str, str, str]]: + """Return the list of (node_id, ip_address) to deploy HTTP and gRPC servers + on.""" + location = self._http_options.location + + if location == DeploymentMode.NoServer: + return [] + + target_nodes = [ + (node_id, ip_address, instance_id) + for node_id, ip_address, instance_id in self._cluster_node_info_cache.get_alive_nodes() + if node_id in proxy_nodes + ] + + if location == DeploymentMode.HeadOnly: + nodes = [ + (node_id, ip_address, instance_id) + for node_id, ip_address, instance_id in target_nodes + if node_id == self._head_node_id + ] + assert len(nodes) == 1, ( + f"Head node not found! Head node id: {self._head_node_id}, " + f"all nodes: {target_nodes}." + ) + return nodes + + return target_nodes + + def _generate_actor_name(self, node_id: str) -> str: + return format_actor_name(SERVE_PROXY_NAME, node_id) + + def _start_proxy( + self, + name: str, + node_id: str, + node_ip_address: str, + ) -> ProxyWrapper: + """Helper to start or reuse existing proxy and wrap in the proxy actor wrapper. + + Compute the HTTP port based on `TEST_WORKER_NODE_HTTP_PORT` env var and gRPC + port based on `TEST_WORKER_NODE_GRPC_PORT` env var. Passed all the required + variables into the proxy actor wrapper class and return the proxy actor wrapper. + """ + http_options = self._http_options + grpc_options = self._grpc_options + + if ( + node_id != self._head_node_id + and os.getenv("TEST_WORKER_NODE_HTTP_PORT") is not None + ): + logger.warning( + f"`TEST_WORKER_NODE_HTTP_PORT` env var is set. " + f"Using it for worker node {node_id}." + ) + http_options = deepcopy(http_options) + http_options.port = int(os.getenv("TEST_WORKER_NODE_HTTP_PORT")) + + if ( + node_id != self._head_node_id + and os.getenv("TEST_WORKER_NODE_GRPC_PORT") is not None + ): + logger.warning( + f"`TEST_WORKER_NODE_GRPC_PORT` env var is set. " + f"Using it for worker node {node_id}." + f"{int(os.getenv('TEST_WORKER_NODE_GRPC_PORT'))}" + ) + grpc_options = deepcopy(grpc_options) + grpc_options.port = int(os.getenv("TEST_WORKER_NODE_GRPC_PORT")) + + return self._actor_proxy_wrapper_class( + logging_config=self.logging_config, + http_options=http_options, + grpc_options=grpc_options, + name=name, + node_id=node_id, + node_ip_address=node_ip_address, + proxy_actor_class=self._proxy_actor_class, + ) + + def _start_proxies_if_needed(self, target_nodes) -> None: + """Start a proxy on every node if it doesn't already exist.""" + + for node_id, node_ip_address, node_instance_id in target_nodes: + if node_id in self._proxy_states: + continue + + name = self._generate_actor_name(node_id=node_id) + actor_proxy_wrapper = self._start_proxy( + name=name, + node_id=node_id, + node_ip_address=node_ip_address, + ) + + self._proxy_states[node_id] = ProxyState( + actor_proxy_wrapper=actor_proxy_wrapper, + actor_name=name, + node_id=node_id, + node_ip=node_ip_address, + node_instance_id=node_instance_id, + proxy_restart_count=self._proxy_restart_counts.get(node_id, 0), + timer=self._timer, + ) + + def _stop_proxies_if_needed(self) -> bool: + """Removes proxy actors. + + Removes proxy actors from any nodes that no longer exist or unhealthy proxy. + """ + alive_node_ids = self._cluster_node_info_cache.get_alive_node_ids() + to_stop = [] + for node_id, proxy_state in self._proxy_states.items(): + if node_id not in alive_node_ids: + logger.info(f"Removing proxy on removed node '{node_id}'.") + to_stop.append(node_id) + elif proxy_state.status == ProxyStatus.UNHEALTHY: + logger.info( + f"Proxy on node '{node_id}' is unhealthy. Shutting down " + "the unhealthy proxy and starting a new one." + ) + to_stop.append(node_id) + elif proxy_state.status == ProxyStatus.DRAINED: + logger.info(f"Removing drained proxy on node '{node_id}'.") + to_stop.append(node_id) + + for node_id in to_stop: + proxy_state = self._proxy_states.pop(node_id) + self._proxy_restart_counts[node_id] = proxy_state.proxy_restart_count + 1 + proxy_state.shutdown() + + +def _try_set_exception(fut: asyncio.Future, e: Exception): + if not fut.done(): + fut.set_exception(e) + + +def wrap_as_future(ref: ObjectRef, timeout_s: Optional[float] = None) -> asyncio.Future: + loop = asyncio.get_running_loop() + + aio_fut = asyncio.wrap_future(ref.future()) + + if timeout_s is not None: + assert timeout_s >= 0, "Timeout value should be non-negative" + # Schedule handler to complete future exceptionally + timeout_handler = loop.call_later( + max(timeout_s, 0), + _try_set_exception, + aio_fut, + TimeoutError(f"Future cancelled after timeout {timeout_s}s"), + ) + # Cancel timeout handler upon completion of the future + aio_fut.add_done_callback(lambda _: timeout_handler.cancel()) + + return aio_fut diff --git a/lib/python3.12/site-packages/ray/serve/_private/replica.py b/lib/python3.12/site-packages/ray/serve/_private/replica.py new file mode 100644 index 0000000000000000000000000000000000000000..cbfa31c91a80f04b93af806f2265bd31d9a71b2a --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/_private/replica.py @@ -0,0 +1,2229 @@ +import asyncio +import concurrent.futures +import functools +import inspect +import logging +import os +import pickle +import threading +import time +import traceback +import warnings +from abc import ABC, abstractmethod +from collections import defaultdict, deque +from contextlib import asynccontextmanager, contextmanager +from dataclasses import dataclass +from importlib import import_module +from typing import ( + Any, + AsyncGenerator, + Callable, + Dict, + Generator, + List, + Optional, + Set, + Tuple, + Union, +) + +import starlette.responses +from anyio import to_thread +from fastapi import Request +from starlette.applications import Starlette +from starlette.types import ASGIApp, Receive, Scope, Send + +import ray +from ray import cloudpickle +from ray._common.filters import CoreContextFilter +from ray._common.utils import get_or_create_event_loop +from ray.actor import ActorClass, ActorHandle +from ray.dag.py_obj_scanner import _PyObjScanner +from ray.remote_function import RemoteFunction +from ray.serve import metrics +from ray.serve._private.common import ( + RUNNING_REQUESTS_KEY, + DeploymentID, + ReplicaID, + ReplicaMetricReport, + ReplicaQueueLengthInfo, + RequestMetadata, + ServeComponentType, + StreamingHTTPRequest, + gRPCRequest, +) +from ray.serve._private.config import DeploymentConfig +from ray.serve._private.constants import ( + GRPC_CONTEXT_ARG_NAME, + HEALTH_CHECK_METHOD, + RAY_SERVE_COLLECT_AUTOSCALING_METRICS_ON_HANDLE, + RAY_SERVE_METRICS_EXPORT_INTERVAL_MS, + RAY_SERVE_RECORD_AUTOSCALING_STATS_TIMEOUT_S, + RAY_SERVE_REPLICA_AUTOSCALING_METRIC_RECORD_INTERVAL_S, + RAY_SERVE_REQUEST_PATH_LOG_BUFFER_SIZE, + RAY_SERVE_RUN_SYNC_IN_THREADPOOL, + RAY_SERVE_RUN_SYNC_IN_THREADPOOL_WARNING, + RAY_SERVE_RUN_USER_CODE_IN_SEPARATE_THREAD, + RECONFIGURE_METHOD, + REQUEST_LATENCY_BUCKETS_MS, + REQUEST_ROUTING_STATS_METHOD, + SERVE_CONTROLLER_NAME, + SERVE_LOG_APPLICATION, + SERVE_LOG_COMPONENT, + SERVE_LOG_DEPLOYMENT, + SERVE_LOG_REPLICA, + SERVE_LOG_REQUEST_ID, + SERVE_LOG_ROUTE, + SERVE_LOGGER_NAME, + SERVE_NAMESPACE, +) +from ray.serve._private.default_impl import ( + create_replica_impl, + create_replica_metrics_manager, +) +from ray.serve._private.http_util import ( + ASGIAppReplicaWrapper, + ASGIArgs, + ASGIReceiveProxy, + MessageQueue, + Response, +) +from ray.serve._private.logging_utils import ( + access_log_msg, + configure_component_logger, + configure_component_memory_profiler, + get_component_logger_file_path, +) +from ray.serve._private.metrics_utils import InMemoryMetricsStore, MetricsPusher +from ray.serve._private.task_consumer import TaskConsumerWrapper +from ray.serve._private.thirdparty.get_asgi_route_name import ( + extract_route_patterns, + get_asgi_route_name, +) +from ray.serve._private.usage import ServeUsageTag +from ray.serve._private.utils import ( + Semaphore, + get_component_file_name, # noqa: F401 + parse_import_path, +) +from ray.serve._private.version import DeploymentVersion +from ray.serve.config import AutoscalingConfig +from ray.serve.context import _get_in_flight_requests +from ray.serve.deployment import Deployment +from ray.serve.exceptions import ( + BackPressureError, + DeploymentUnavailableError, + RayServeException, +) +from ray.serve.handle import DeploymentHandle +from ray.serve.schema import EncodingType, LoggingConfig, ReplicaRank + +logger = logging.getLogger(SERVE_LOGGER_NAME) + + +ReplicaMetadata = Tuple[ + DeploymentConfig, + DeploymentVersion, + Optional[float], + Optional[int], + Optional[str], + int, + int, + ReplicaRank, # rank + Optional[List[str]], # route_patterns + Optional[List[DeploymentID]], # outbound_deployments +] + + +def _load_deployment_def_from_import_path(import_path: str) -> Callable: + module_name, attr_name = parse_import_path(import_path) + deployment_def = getattr(import_module(module_name), attr_name) + + # For ray or serve decorated class or function, strip to return + # original body. + if isinstance(deployment_def, RemoteFunction): + deployment_def = deployment_def._function + elif isinstance(deployment_def, ActorClass): + deployment_def = deployment_def.__ray_metadata__.modified_class + elif isinstance(deployment_def, Deployment): + logger.warning( + f'The import path "{import_path}" contains a ' + "decorated Serve deployment. The decorator's settings " + "are ignored when deploying via import path." + ) + deployment_def = deployment_def.func_or_class + + return deployment_def + + +class ReplicaMetricsManager: + """Manages metrics for the replica. + + A variety of metrics are managed: + - Fine-grained metrics are set for every request. + - Autoscaling statistics are periodically pushed to the controller. + - Queue length metrics are periodically recorded as user-facing gauges. + """ + + PUSH_METRICS_TO_CONTROLLER_TASK_NAME = "push_metrics_to_controller" + RECORD_METRICS_TASK_NAME = "record_metrics" + SET_REPLICA_REQUEST_METRIC_GAUGE_TASK_NAME = "set_replica_request_metric_gauge" + + def __init__( + self, + replica_id: ReplicaID, + event_loop: asyncio.BaseEventLoop, + autoscaling_config: Optional[AutoscalingConfig], + ingress: bool, + ): + self._replica_id = replica_id + self._deployment_id = replica_id.deployment_id + self._metrics_pusher = MetricsPusher() + self._metrics_store = InMemoryMetricsStore() + self._ingress = ingress + self._controller_handle = ray.get_actor( + SERVE_CONTROLLER_NAME, namespace=SERVE_NAMESPACE + ) + self._num_ongoing_requests = 0 + # Store event loop for scheduling async tasks from sync context + self._event_loop = event_loop or asyncio.get_event_loop() + + # Cache user_callable_wrapper initialization state to avoid repeated runtime checks + self._custom_metrics_enabled = False + # On first call to _fetch_custom_autoscaling_metrics. Failing validation disables _custom_metrics_enabled + self._checked_custom_metrics = False + self._record_autoscaling_stats_fn = None + + # If the interval is set to 0, eagerly sets all metrics. + self._cached_metrics_enabled = RAY_SERVE_METRICS_EXPORT_INTERVAL_MS != 0 + self._cached_metrics_interval_s = RAY_SERVE_METRICS_EXPORT_INTERVAL_MS / 1000 + + # Request counter (only set on replica startup). + self._restart_counter = metrics.Counter( + "serve_deployment_replica_starts", + description=( + "The number of times this replica has been restarted due to failure." + ), + ) + self._restart_counter.inc() + + # Per-request metrics. + self._request_counter = metrics.Counter( + "serve_deployment_request_counter", + description=( + "The number of queries that have been processed in this replica." + ), + tag_keys=("route",), + ) + if self._cached_metrics_enabled: + self._cached_request_counter = defaultdict(int) + + self._error_counter = metrics.Counter( + "serve_deployment_error_counter", + description=( + "The number of exceptions that have occurred in this replica." + ), + tag_keys=("route",), + ) + if self._cached_metrics_enabled: + self._cached_error_counter = defaultdict(int) + + # log REQUEST_LATENCY_BUCKET_MS + logger.debug(f"REQUEST_LATENCY_BUCKETS_MS: {REQUEST_LATENCY_BUCKETS_MS}") + self._processing_latency_tracker = metrics.Histogram( + "serve_deployment_processing_latency_ms", + description="The latency for queries to be processed.", + boundaries=REQUEST_LATENCY_BUCKETS_MS, + tag_keys=("route",), + ) + if self._cached_metrics_enabled: + self._cached_latencies = defaultdict(deque) + self._event_loop.create_task(self._report_cached_metrics_forever()) + + self._num_ongoing_requests_gauge = metrics.Gauge( + "serve_replica_processing_queries", + description="The current number of queries being processed.", + ) + + self.record_autoscaling_stats_failed_counter = metrics.Counter( + "serve_record_autoscaling_stats_failed", + description="The number of errored record_autoscaling_stats invocations.", + tag_keys=("app_name", "deployment_name", "replica_id", "exception_name"), + ) + + self.set_autoscaling_config(autoscaling_config) + + def _report_cached_metrics(self): + for route, count in self._cached_request_counter.items(): + self._request_counter.inc(count, tags={"route": route}) + self._cached_request_counter.clear() + + for route, count in self._cached_error_counter.items(): + self._error_counter.inc(count, tags={"route": route}) + self._cached_error_counter.clear() + + for route, latencies in self._cached_latencies.items(): + for latency_ms in latencies: + self._processing_latency_tracker.observe( + latency_ms, tags={"route": route} + ) + self._cached_latencies.clear() + + self._num_ongoing_requests_gauge.set(self._num_ongoing_requests) + + async def _report_cached_metrics_forever(self): + assert self._cached_metrics_interval_s > 0 + + consecutive_errors = 0 + while True: + try: + await asyncio.sleep(self._cached_metrics_interval_s) + self._report_cached_metrics() + consecutive_errors = 0 + except Exception: + logger.exception("Unexpected error reporting metrics.") + + # Exponential backoff starting at 1s and capping at 10s. + backoff_time_s = min(10, 2**consecutive_errors) + consecutive_errors += 1 + await asyncio.sleep(backoff_time_s) + + async def shutdown(self): + """Stop periodic background tasks.""" + + await self._metrics_pusher.graceful_shutdown() + + def start_metrics_pusher(self): + self._metrics_pusher.start() + + # Push autoscaling metrics to the controller periodically. + self._metrics_pusher.register_or_update_task( + self.PUSH_METRICS_TO_CONTROLLER_TASK_NAME, + self._push_autoscaling_metrics, + self._autoscaling_config.metrics_interval_s, + ) + # Collect autoscaling metrics locally periodically. + self._metrics_pusher.register_or_update_task( + self.RECORD_METRICS_TASK_NAME, + self._add_autoscaling_metrics_point_async, + min( + RAY_SERVE_REPLICA_AUTOSCALING_METRIC_RECORD_INTERVAL_S, + self._autoscaling_config.metrics_interval_s, + ), + ) + + def should_collect_ongoing_requests(self) -> bool: + """Determine if replicas should collect ongoing request metrics. + + ┌────────────────────────────────────────────────────────────────┐ + │ Replica-based metrics collection │ + ├────────────────────────────────────────────────────────────────┤ + │ │ + │ Client Handle Replicas │ + │ ┌──────┐ ┌────────┐ │ + │ │ App │─────>│ Handle │────┬───>┌─────────┐ │ + │ │ │ │ Tracks │ │ │ Replica │ │ + │ └──────┘ │ Queued │ │ │ 1 │ │ + │ │Requests│ │ │ Tracks │ │ + │ └────────┘ │ │ Running │ │ + │ │ │ └─────────┘ │ + │ │ │ │ │ + │ │ │ │ │ + │ │ │ ┌─────────┐ │ + │ │ └───>│ Replica │ │ + │ │ │ 2 │ │ + │ │ │ Tracks │ │ + │ │ │ Running │ │ + │ │ └─────────┘ │ + │ │ │ │ + │ │ │ │ + │ ▼ ▼ │ + │ ┌──────────────────────────────┐ │ + │ │ Controller │ │ + │ │ • Queued metrics (handle) │ │ + │ │ • Running metrics (replica1)│ │ + │ │ • Running metrics (replica2)│ │ + │ └──────────────────────────────┘ │ + │ │ + └────────────────────────────────────────────────────────────────┘ + """ + return not RAY_SERVE_COLLECT_AUTOSCALING_METRICS_ON_HANDLE + + def set_autoscaling_config(self, autoscaling_config: Optional[AutoscalingConfig]): + """Dynamically update autoscaling config.""" + + self._autoscaling_config = autoscaling_config + + if self._autoscaling_config and self.should_collect_ongoing_requests(): + self.start_metrics_pusher() + + def enable_custom_autoscaling_metrics( + self, + custom_metrics_enabled: bool, + record_autoscaling_stats_fn: Callable[[], Optional[concurrent.futures.Future]], + ): + """Runs after the user callable wrapper is initialized to enable autoscaling metrics collection.""" + if custom_metrics_enabled: + self._custom_metrics_enabled = custom_metrics_enabled + self._record_autoscaling_stats_fn = record_autoscaling_stats_fn + self.start_metrics_pusher() + + def inc_num_ongoing_requests(self, request_metadata: RequestMetadata) -> int: + """Increment the current total queue length of requests for this replica.""" + self._num_ongoing_requests += 1 + if not self._cached_metrics_enabled: + self._num_ongoing_requests_gauge.set(self._num_ongoing_requests) + + def dec_num_ongoing_requests(self, request_metadata: RequestMetadata) -> int: + """Decrement the current total queue length of requests for this replica.""" + self._num_ongoing_requests -= 1 + if not self._cached_metrics_enabled: + self._num_ongoing_requests_gauge.set(self._num_ongoing_requests) + + def get_num_ongoing_requests(self) -> int: + """Get current total queue length of requests for this replica.""" + return self._num_ongoing_requests + + def record_request_metrics(self, *, route: str, latency_ms: float, was_error: bool): + """Records per-request metrics.""" + if self._cached_metrics_enabled: + self._cached_latencies[route].append(latency_ms) + if was_error: + self._cached_error_counter[route] += 1 + else: + self._cached_request_counter[route] += 1 + else: + self._processing_latency_tracker.observe(latency_ms, tags={"route": route}) + if was_error: + self._error_counter.inc(tags={"route": route}) + else: + self._request_counter.inc(tags={"route": route}) + + def _push_autoscaling_metrics(self) -> Dict[str, Any]: + look_back_period = self._autoscaling_config.look_back_period_s + self._metrics_store.prune_keys_and_compact_data(time.time() - look_back_period) + + new_aggregated_metrics = {} + new_metrics = {**self._metrics_store.data} + + if self.should_collect_ongoing_requests(): + # Keep the legacy window_avg ongoing requests in the merged metrics dict + window_avg = ( + self._metrics_store.aggregate_avg([RUNNING_REQUESTS_KEY])[0] or 0.0 + ) + new_aggregated_metrics.update({RUNNING_REQUESTS_KEY: window_avg}) + + replica_metric_report = ReplicaMetricReport( + replica_id=self._replica_id, + timestamp=time.time(), + aggregated_metrics=new_aggregated_metrics, + metrics=new_metrics, + ) + self._controller_handle.record_autoscaling_metrics_from_replica.remote( + replica_metric_report + ) + + async def _fetch_custom_autoscaling_metrics( + self, + ) -> Optional[Dict[str, Union[int, float]]]: + try: + res = await asyncio.wait_for( + self._record_autoscaling_stats_fn(), + timeout=RAY_SERVE_RECORD_AUTOSCALING_STATS_TIMEOUT_S, + ) + + # Perform validation only first call + if not self._checked_custom_metrics: + # Enforce return type to be Dict[str, Union[int, float]] + if not isinstance(res, dict): + logger.error( + f"User autoscaling stats method returned {type(res).__name__}, " + f"expected Dict[str, Union[int, float]]. Disabling autoscaling stats." + ) + self._custom_metrics_enabled = False + return None + + for key, value in res.items(): + if not isinstance(value, (int, float)): + logger.error( + f"User autoscaling stats method returned invalid value type " + f"{type(value).__name__} for key '{key}', expected int or float. " + f"Disabling autoscaling stats." + ) + self._custom_metrics_enabled = False + return None + + self._checked_custom_metrics = True + + return res + except asyncio.TimeoutError as timeout_err: + logger.error( + f"Replica autoscaling stats timed out after {RAY_SERVE_RECORD_AUTOSCALING_STATS_TIMEOUT_S}s." + ) + self.record_autoscaling_stats_failed_counter.inc( + tags={ + "app_name": self._deployment_id.app_name, + "deployment_name": self._deployment_id.name, + "replica_id": self._replica_id.unique_id, + "exception_name": timeout_err.__class__.__name__, + } + ) + except Exception as err: + logger.error(f"Replica autoscaling stats failed. {err}") + self.record_autoscaling_stats_failed_counter.inc( + tags={ + "app_name": self._deployment_id.app_name, + "deployment_name": self._deployment_id.name, + "replica_id": self._replica_id.unique_id, + "exception_name": err.__class__.__name__, + } + ) + return None + + async def _add_autoscaling_metrics_point_async(self) -> None: + metrics_dict = {} + if self.should_collect_ongoing_requests(): + metrics_dict = {RUNNING_REQUESTS_KEY: self._num_ongoing_requests} + + # Use cached availability flag to avoid repeated runtime checks + if self._custom_metrics_enabled: + custom_metrics = await self._fetch_custom_autoscaling_metrics() + if custom_metrics: + metrics_dict.update(custom_metrics) + + self._metrics_store.add_metrics_point( + metrics_dict, + time.time(), + ) + + +StatusCodeCallback = Callable[[str], None] + + +class ReplicaBase(ABC): + def __init__( + self, + replica_id: ReplicaID, + deployment_def: Callable, + init_args: Tuple, + init_kwargs: Dict, + deployment_config: DeploymentConfig, + version: DeploymentVersion, + ingress: bool, + route_prefix: str, + rank: ReplicaRank, + ): + self._version = version + self._replica_id = replica_id + self._deployment_id = replica_id.deployment_id + self._deployment_config = deployment_config + self._ingress = ingress + self._route_prefix = route_prefix + self._component_name = f"{self._deployment_id.name}" + if self._deployment_id.app_name: + self._component_name = ( + f"{self._deployment_id.app_name}_" + self._component_name + ) + + self._component_id = self._replica_id.unique_id + self._configure_logger_and_profilers(self._deployment_config.logging_config) + self._event_loop = get_or_create_event_loop() + + self._user_callable_wrapper = UserCallableWrapper( + deployment_def, + init_args, + init_kwargs, + deployment_id=self._deployment_id, + run_sync_methods_in_threadpool=RAY_SERVE_RUN_SYNC_IN_THREADPOOL, + run_user_code_in_separate_thread=RAY_SERVE_RUN_USER_CODE_IN_SEPARATE_THREAD, + local_testing_mode=False, + deployment_config=deployment_config, + ) + self._semaphore = Semaphore(lambda: self.max_ongoing_requests) + + # Guards against calling the user's callable constructor multiple times. + self._user_callable_initialized = False + self._user_callable_initialized_lock = asyncio.Lock() + self._initialization_latency: Optional[float] = None + + # Track deployment handles created dynamically via get_deployment_handle() + self._dynamically_created_handles: Set[DeploymentID] = set() + + # Flipped to `True` when health checks pass and `False` when they fail. May be + # used by replica subclass implementations. + self._healthy = False + # Flipped to `True` once graceful shutdown is initiated. May be used by replica + # subclass implementations. + self._shutting_down = False + + # Will be populated with the wrapped ASGI app if the user callable is an + # `ASGIAppReplicaWrapper` (i.e., they are using the FastAPI integration). + self._user_callable_asgi_app: Optional[ASGIApp] = None + + # Set metadata for logs and metrics. + # servable_object will be populated in `initialize_and_get_metadata`. + self._set_internal_replica_context(servable_object=None, rank=rank) + + self._metrics_manager = create_replica_metrics_manager( + replica_id=replica_id, + event_loop=self._event_loop, + autoscaling_config=self._deployment_config.autoscaling_config, + ingress=ingress, + ) + + self._internal_grpc_port: Optional[int] = None + self._docs_path: Optional[str] = None + self._http_port: Optional[int] = None + self._grpc_port: Optional[int] = None + + self._rank = rank + + @property + def max_ongoing_requests(self) -> int: + return self._deployment_config.max_ongoing_requests + + def get_num_ongoing_requests(self) -> int: + return self._metrics_manager.get_num_ongoing_requests() + + def get_metadata(self) -> ReplicaMetadata: + current_rank = ray.serve.context._get_internal_replica_context().rank + # Extract route patterns from ASGI app if available + route_patterns = None + if self._user_callable_asgi_app is not None: + # _user_callable_asgi_app is the actual ASGI app (FastAPI/Starlette) + # It's set when initialize_callable() returns an ASGI app + if hasattr(self._user_callable_asgi_app, "routes"): + route_patterns = extract_route_patterns(self._user_callable_asgi_app) + + return ( + self._version.deployment_config, + self._version, + self._initialization_latency, + self._internal_grpc_port, + self._docs_path, + self._http_port, + self._grpc_port, + current_rank, + route_patterns, + self.list_outbound_deployments(), + ) + + def get_dynamically_created_handles(self) -> Set[DeploymentID]: + return self._dynamically_created_handles + + def list_outbound_deployments(self) -> List[DeploymentID]: + """List all outbound deployment IDs this replica calls into. + + This includes: + - Handles created via get_deployment_handle() + - Handles passed as init args/kwargs to the deployment constructor + + This is used to determine which deployments are reachable from this replica. + The list of DeploymentIDs can change over time as new handles can be created at runtime. + Also its not guaranteed that the list of DeploymentIDs are identical across replicas + because it depends on user code. + + Returns: + A list of DeploymentIDs that this replica calls into. + """ + seen_deployment_ids: Set[DeploymentID] = set() + + # First, collect dynamically created handles + for deployment_id in self.get_dynamically_created_handles(): + seen_deployment_ids.add(deployment_id) + + # Get the init args/kwargs + init_args = self._user_callable_wrapper._init_args + init_kwargs = self._user_callable_wrapper._init_kwargs + + # Use _PyObjScanner to find all DeploymentHandle objects in: + # The init_args and init_kwargs (handles might be passed as init args) + scanner = _PyObjScanner(source_type=DeploymentHandle) + try: + handles = scanner.find_nodes((init_args, init_kwargs)) + + for handle in handles: + deployment_id = handle.deployment_id + seen_deployment_ids.add(deployment_id) + finally: + scanner.clear() + + return list(seen_deployment_ids) + + def _set_internal_replica_context( + self, *, servable_object: Callable = None, rank: ReplicaRank = None + ): + # Calculate world_size from deployment config instead of storing it + world_size = self._deployment_config.num_replicas + + # Create callback for registering dynamically created handles + def register_handle_callback(deployment_id: DeploymentID) -> None: + self._dynamically_created_handles.add(deployment_id) + + ray.serve.context._set_internal_replica_context( + replica_id=self._replica_id, + servable_object=servable_object, + _deployment_config=self._deployment_config, + rank=rank, + world_size=world_size, + handle_registration_callback=register_handle_callback, + ) + + def _configure_logger_and_profilers( + self, logging_config: Union[None, Dict, LoggingConfig] + ): + + if logging_config is None: + logging_config = {} + if isinstance(logging_config, dict): + logging_config = LoggingConfig(**logging_config) + + configure_component_logger( + component_type=ServeComponentType.REPLICA, + component_name=self._component_name, + component_id=self._component_id, + logging_config=logging_config, + buffer_size=RAY_SERVE_REQUEST_PATH_LOG_BUFFER_SIZE, + ) + configure_component_memory_profiler( + component_type=ServeComponentType.REPLICA, + component_name=self._component_name, + component_id=self._component_id, + ) + + if logging_config.encoding == EncodingType.JSON: + # Create logging context for access logs as a performance optimization. + # While logging_utils can automatically add Ray core and Serve access log context, + # we pre-compute it here since context evaluation is expensive and this context + # will be reused for multiple access log entries. + ray_core_logging_context = CoreContextFilter.get_ray_core_logging_context() + # remove task level log keys from ray core logging context, it would be nice + # to have task level log keys here but we are letting those go in favor of + # performance optimization. Also we cannot include task level log keys here because + # they would referance the current task (__init__) and not the task that is logging. + for key in CoreContextFilter.TASK_LEVEL_LOG_KEYS: + ray_core_logging_context.pop(key, None) + self._access_log_context = { + **ray_core_logging_context, + SERVE_LOG_DEPLOYMENT: self._component_name, + SERVE_LOG_REPLICA: self._component_id, + SERVE_LOG_COMPONENT: ServeComponentType.REPLICA, + SERVE_LOG_APPLICATION: self._deployment_id.app_name, + "skip_context_filter": True, + "serve_access_log": True, + } + else: + self._access_log_context = { + "skip_context_filter": True, + "serve_access_log": True, + } + + def _can_accept_request(self, request_metadata: RequestMetadata) -> bool: + # This replica gates concurrent request handling with an asyncio.Semaphore. + # Each in-flight request acquires the semaphore. When the number of ongoing + # requests reaches max_ongoing_requests, the semaphore becomes locked. + # A new request can be accepted if the semaphore is currently unlocked. + return not self._semaphore.locked() + + def _maybe_get_http_route( + self, request_metadata: RequestMetadata, request_args: Tuple[Any] + ) -> Optional[str]: + """Get the matched route string for ASGI apps to be used in logs & metrics. + + If this replica does not wrap an ASGI app or there is no matching for the + request, returns the existing route from the request metadata. + """ + route = request_metadata.route + if self._user_callable_asgi_app is not None: + req: StreamingHTTPRequest = request_args[0] + try: + matched_route = get_asgi_route_name( + self._user_callable_asgi_app, req.asgi_scope + ) + except Exception: + matched_route = None + logger.exception( + "Failed unexpectedly trying to get route name for request. " + "Routes in metric tags and log messages may be inaccurate. " + "Please file a GitHub issue containing this traceback." + ) + + # If there is no match in the ASGI app, don't overwrite the route_prefix + # from the proxy. + if matched_route is not None: + route = matched_route + + return route + + @contextmanager + def _handle_errors_and_metrics( + self, request_metadata: RequestMetadata + ) -> Generator[StatusCodeCallback, None, None]: + start_time = time.time() + user_exception = None + + status_code = None + + def _status_code_callback(s: str): + nonlocal status_code + status_code = s + + try: + yield _status_code_callback + except asyncio.CancelledError as e: + user_exception = e + self._on_request_cancelled(request_metadata, e) + except Exception as e: + user_exception = e + logger.exception("Request failed.") + self._on_request_failed(request_metadata, e) + + latency_ms = (time.time() - start_time) * 1000 + self._record_errors_and_metrics( + user_exception, status_code, latency_ms, request_metadata + ) + + if user_exception is not None: + raise user_exception from None + + def _record_errors_and_metrics( + self, + user_exception: Optional[BaseException], + status_code: Optional[str], + latency_ms: float, + request_metadata: RequestMetadata, + ): + http_method = request_metadata._http_method + http_route = request_metadata.route + call_method = request_metadata.call_method + if user_exception is None: + status_str = "OK" + elif isinstance(user_exception, asyncio.CancelledError): + status_str = "CANCELLED" + else: + status_str = "ERROR" + + # Mutating self._access_log_context is not thread safe, but since this + # is only called from the same thread, it is safe. Mutating the same object + # because creating a new dict is expensive. + self._access_log_context[SERVE_LOG_ROUTE] = http_route + self._access_log_context[SERVE_LOG_REQUEST_ID] = request_metadata.request_id + logger.info( + access_log_msg( + method=http_method or "CALL", + route=http_route if self._ingress and http_route else call_method, + # Prefer the HTTP status code if it was populated. + status=status_code or status_str, + latency_ms=latency_ms, + ), + extra=self._access_log_context, + ) + self._metrics_manager.record_request_metrics( + route=http_route, + latency_ms=latency_ms, + was_error=user_exception is not None, + ) + + def _unpack_proxy_args( + self, + request_metadata: RequestMetadata, + request_args: Tuple[Any], + request_kwargs: Dict[str, Any], + ): + if request_metadata.is_http_request: + assert len(request_args) == 1 and isinstance( + request_args[0], StreamingHTTPRequest + ) + request: StreamingHTTPRequest = request_args[0] + scope = request.asgi_scope + receive = ASGIReceiveProxy( + scope, request_metadata, request.receive_asgi_messages + ) + + request_metadata._http_method = scope.get("method", "WS") + request_metadata.route = self._maybe_get_http_route( + request_metadata, request_args + ) + + request_args = (scope, receive) + elif request_metadata.is_grpc_request: + assert len(request_args) == 1 and isinstance(request_args[0], gRPCRequest) + request: gRPCRequest = request_args[0] + + method_info = self._user_callable_wrapper.get_user_method_info( + request_metadata.call_method + ) + request_args = (request.user_request_proto,) + request_kwargs = ( + {GRPC_CONTEXT_ARG_NAME: request_metadata.grpc_context} + if method_info.takes_grpc_context_kwarg + else {} + ) + + return request_args, request_kwargs + + async def handle_request( + self, request_metadata: RequestMetadata, *request_args, **request_kwargs + ) -> Tuple[bytes, Any]: + request_args, request_kwargs = self._unpack_proxy_args( + request_metadata, request_args, request_kwargs + ) + with self._wrap_request(request_metadata): + async with self._start_request(request_metadata): + return await self._user_callable_wrapper.call_user_method( + request_metadata, request_args, request_kwargs + ) + + async def handle_request_streaming( + self, request_metadata: RequestMetadata, *request_args, **request_kwargs + ) -> AsyncGenerator[Any, None]: + """Generator that is the entrypoint for all `stream=True` handle calls.""" + request_args, request_kwargs = self._unpack_proxy_args( + request_metadata, request_args, request_kwargs + ) + with self._wrap_request(request_metadata) as status_code_callback: + async with self._start_request(request_metadata): + if request_metadata.is_http_request: + scope, receive = request_args + async for msgs in self._user_callable_wrapper.call_http_entrypoint( + request_metadata, + status_code_callback, + scope, + receive, + ): + yield pickle.dumps(msgs) + else: + async for result in self._user_callable_wrapper.call_user_generator( + request_metadata, + request_args, + request_kwargs, + ): + yield result + + async def handle_request_with_rejection( + self, request_metadata: RequestMetadata, *request_args, **request_kwargs + ): + # Check if the replica has capacity for the request. + if not self._can_accept_request(request_metadata): + limit = self.max_ongoing_requests + logger.warning( + f"Replica at capacity of max_ongoing_requests={limit}, " + f"rejecting request {request_metadata.request_id}.", + extra={"log_to_stderr": False}, + ) + yield ReplicaQueueLengthInfo(False, self.get_num_ongoing_requests()) + return + + request_args, request_kwargs = self._unpack_proxy_args( + request_metadata, request_args, request_kwargs + ) + with self._wrap_request(request_metadata) as status_code_callback: + async with self._start_request(request_metadata): + yield ReplicaQueueLengthInfo( + accepted=True, + # NOTE(edoakes): `_wrap_request` will increment the number + # of ongoing requests to include this one, so re-fetch the value. + num_ongoing_requests=self.get_num_ongoing_requests(), + ) + + if request_metadata.is_http_request: + scope, receive = request_args + async for msgs in self._user_callable_wrapper.call_http_entrypoint( + request_metadata, + status_code_callback, + scope, + receive, + ): + yield pickle.dumps(msgs) + elif request_metadata.is_streaming: + async for result in self._user_callable_wrapper.call_user_generator( + request_metadata, + request_args, + request_kwargs, + ): + yield result + else: + yield await self._user_callable_wrapper.call_user_method( + request_metadata, request_args, request_kwargs + ) + + @abstractmethod + async def _on_initialized(self): + raise NotImplementedError + + async def initialize(self, deployment_config: DeploymentConfig): + try: + # Ensure that initialization is only performed once. + # When controller restarts, it will call this method again. + async with self._user_callable_initialized_lock: + self._initialization_start_time = time.time() + if not self._user_callable_initialized: + self._user_callable_asgi_app = ( + await self._user_callable_wrapper.initialize_callable() + ) + if self._user_callable_asgi_app: + self._docs_path = ( + self._user_callable_wrapper._callable.docs_path + ) + await self._on_initialized() + self._user_callable_initialized = True + + if self._user_callable_wrapper is not None: + initialized = ( + hasattr( + self._user_callable_wrapper, "_user_autoscaling_stats" + ) + and self._user_callable_wrapper._user_autoscaling_stats + is not None + ) + + self._metrics_manager.enable_custom_autoscaling_metrics( + custom_metrics_enabled=initialized, + record_autoscaling_stats_fn=self._user_callable_wrapper.call_record_autoscaling_stats, + ) + + if deployment_config: + await self._user_callable_wrapper.set_sync_method_threadpool_limit( + deployment_config.max_ongoing_requests + ) + rank = ray.serve.context._get_internal_replica_context().rank + await self._user_callable_wrapper.call_reconfigure( + deployment_config.user_config, + rank=rank, + ) + + # A new replica should not be considered healthy until it passes + # an initial health check. If an initial health check fails, + # consider it an initialization failure. + await self.check_health() + except Exception: + raise RuntimeError(traceback.format_exc()) from None + + async def reconfigure( + self, + deployment_config: DeploymentConfig, + rank: ReplicaRank, + route_prefix: Optional[str] = None, + ): + try: + user_config_changed = ( + deployment_config.user_config != self._deployment_config.user_config + ) + rank_changed = rank != self._rank + self._rank = rank + logging_config_changed = ( + deployment_config.logging_config + != self._deployment_config.logging_config + ) + self._deployment_config = deployment_config + self._version = DeploymentVersion.from_deployment_version( + self._version, deployment_config, route_prefix + ) + + self._metrics_manager.set_autoscaling_config( + deployment_config.autoscaling_config + ) + if logging_config_changed: + self._configure_logger_and_profilers(deployment_config.logging_config) + + await self._user_callable_wrapper.set_sync_method_threadpool_limit( + deployment_config.max_ongoing_requests + ) + if user_config_changed or rank_changed: + await self._user_callable_wrapper.call_reconfigure( + deployment_config.user_config, + rank=rank, + ) + + # We need to update internal replica context to reflect the new + # deployment_config and rank. + self._set_internal_replica_context( + servable_object=self._user_callable_wrapper.user_callable, + rank=rank, + ) + + self._route_prefix = self._version.route_prefix + + except Exception: + raise RuntimeError(traceback.format_exc()) from None + + @abstractmethod + def _on_request_cancelled( + self, request_metadata: RequestMetadata, e: asyncio.CancelledError + ): + pass + + @abstractmethod + def _on_request_failed(self, request_metadata: RequestMetadata, e: Exception): + pass + + @abstractmethod + @contextmanager + def _wrap_request( + self, request_metadata: RequestMetadata + ) -> Generator[StatusCodeCallback, None, None]: + pass + + @asynccontextmanager + async def _start_request(self, request_metadata: RequestMetadata): + async with self._semaphore: + try: + self._metrics_manager.inc_num_ongoing_requests(request_metadata) + yield + finally: + self._metrics_manager.dec_num_ongoing_requests(request_metadata) + + async def _drain_ongoing_requests(self): + """Wait for any ongoing requests to finish. + + Sleep for a grace period before the first time we check the number of ongoing + requests to allow the notification to remove this replica to propagate to + callers first. + """ + wait_loop_period_s = self._deployment_config.graceful_shutdown_wait_loop_s + while True: + await asyncio.sleep(wait_loop_period_s) + + num_ongoing_requests = self._metrics_manager.get_num_ongoing_requests() + if num_ongoing_requests > 0: + logger.info( + f"Waiting for an additional {wait_loop_period_s}s to shut down " + f"because there are {num_ongoing_requests} ongoing requests." + ) + else: + logger.info( + "Graceful shutdown complete; replica exiting.", + extra={"log_to_stderr": False}, + ) + break + + async def shutdown(self): + try: + await self._user_callable_wrapper.call_destructor() + except: # noqa: E722 + # We catch a blanket exception since the constructor may still be + # running, so instance variables used by the destructor may not exist. + if self._user_callable_initialized: + logger.exception( + "__del__ ran before replica finished initializing, and " + "raised an exception." + ) + else: + logger.exception("__del__ raised an exception.") + + await self._metrics_manager.shutdown() + + async def perform_graceful_shutdown(self): + self._shutting_down = True + + # If the replica was never initialized it never served traffic, so we + # can skip the wait period. + if self._user_callable_initialized: + await self._drain_ongoing_requests() + + await self.shutdown() + + async def check_health(self): + try: + # If there's no user-defined health check, nothing runs on the user code event + # loop and no future is returned. + f = self._user_callable_wrapper.call_user_health_check() + if f is not None: + await f + self._healthy = True + except Exception as e: + logger.warning("Replica health check failed.") + self._healthy = False + raise e from None + + async def record_routing_stats(self) -> Dict[str, Any]: + try: + f = self._user_callable_wrapper.call_user_record_routing_stats() + if f is not None: + return await f + return {} + except Exception as e: + logger.warning("Replica record routing stats failed.") + raise e from None + + +class Replica(ReplicaBase): + async def _on_initialized(self): + # Get current rank from replica context during initialization + current_rank = ray.serve.context._get_internal_replica_context().rank + self._set_internal_replica_context( + servable_object=self._user_callable_wrapper.user_callable, + rank=current_rank, + ) + + # Save the initialization latency if the replica is initializing + # for the first time. + if self._initialization_latency is None: + self._initialization_latency = time.time() - self._initialization_start_time + + def _on_request_cancelled( + self, metadata: RequestMetadata, e: asyncio.CancelledError + ): + """Recursively cancels child requests.""" + requests_pending_assignment = ( + ray.serve.context._get_requests_pending_assignment( + metadata.internal_request_id + ) + ) + for task in requests_pending_assignment.values(): + task.cancel() + + # Cancel child requests that have already been assigned. + in_flight_requests = _get_in_flight_requests(metadata.internal_request_id) + for replica_result in in_flight_requests.values(): + replica_result.cancel() + + def _on_request_failed(self, request_metadata: RequestMetadata, e: Exception): + if ray.util.pdb._is_ray_debugger_post_mortem_enabled(): + ray.util.pdb._post_mortem() + + @contextmanager + def _wrap_request( + self, request_metadata: RequestMetadata + ) -> Generator[StatusCodeCallback, None, None]: + """Context manager that wraps user method calls. + + 1) Sets the request context var with appropriate metadata. + 2) Records the access log message (if not disabled). + 3) Records per-request metrics via the metrics manager. + """ + ray.serve.context._serve_request_context.set( + ray.serve.context._RequestContext( + route=request_metadata.route, + request_id=request_metadata.request_id, + _internal_request_id=request_metadata.internal_request_id, + app_name=self._deployment_id.app_name, + multiplexed_model_id=request_metadata.multiplexed_model_id, + grpc_context=request_metadata.grpc_context, + ) + ) + + with self._handle_errors_and_metrics(request_metadata) as status_code_callback: + yield status_code_callback + + +class ReplicaActor: + """Actor definition for replicas of Ray Serve deployments. + + This class defines the interface that the controller and deployment handles + (i.e., from proxies and other replicas) use to interact with a replica. + + All interaction with the user-provided callable is done via the + `UserCallableWrapper` class. + """ + + async def __init__( + self, + replica_id: ReplicaID, + serialized_deployment_def: bytes, + serialized_init_args: bytes, + serialized_init_kwargs: bytes, + deployment_config_proto_bytes: bytes, + version: DeploymentVersion, + ingress: bool, + route_prefix: str, + rank: ReplicaRank, + ): + deployment_config = DeploymentConfig.from_proto_bytes( + deployment_config_proto_bytes + ) + deployment_def = cloudpickle.loads(serialized_deployment_def) + if isinstance(deployment_def, str): + deployment_def = _load_deployment_def_from_import_path(deployment_def) + self._replica_impl: ReplicaBase = create_replica_impl( + replica_id=replica_id, + deployment_def=deployment_def, + init_args=cloudpickle.loads(serialized_init_args), + init_kwargs=cloudpickle.loads(serialized_init_kwargs), + deployment_config=deployment_config, + version=version, + ingress=ingress, + route_prefix=route_prefix, + rank=rank, + ) + + def push_proxy_handle(self, handle: ActorHandle): + # NOTE(edoakes): it's important to call a method on the proxy handle to + # initialize its state in the C++ core worker. + handle.pong.remote() + + def get_num_ongoing_requests(self) -> int: + """Fetch the number of ongoing requests at this replica (queue length). + + This runs on a separate thread (using a Ray concurrency group) so it will + not be blocked by user code. + """ + return self._replica_impl.get_num_ongoing_requests() + + async def is_allocated(self) -> str: + """poke the replica to check whether it's alive. + + When calling this method on an ActorHandle, it will complete as + soon as the actor has started running. We use this mechanism to + detect when a replica has been allocated a worker slot. + At this time, the replica can transition from PENDING_ALLOCATION + to PENDING_INITIALIZATION startup state. + + Returns: + The PID, actor ID, node ID, node IP, and log filepath id of the replica. + """ + + return ( + os.getpid(), + ray.get_runtime_context().get_actor_id(), + ray.get_runtime_context().get_worker_id(), + ray.get_runtime_context().get_node_id(), + ray.util.get_node_ip_address(), + ray.util.get_node_instance_id(), + get_component_logger_file_path(), + ) + + def list_outbound_deployments(self) -> Optional[List[DeploymentID]]: + return self._replica_impl.list_outbound_deployments() + + async def initialize_and_get_metadata( + self, deployment_config: DeploymentConfig = None, _after: Optional[Any] = None + ) -> ReplicaMetadata: + """Handles initializing the replica. + + Returns: 5-tuple containing + 1. DeploymentConfig of the replica + 2. DeploymentVersion of the replica + 3. Initialization duration in seconds + 4. Port + 5. FastAPI `docs_path`, if relevant (i.e. this is an ingress deployment integrated with FastAPI). + """ + # Unused `_after` argument is for scheduling: passing an ObjectRef + # allows delaying this call until after the `_after` call has returned. + await self._replica_impl.initialize(deployment_config) + return self._replica_impl.get_metadata() + + async def check_health(self): + await self._replica_impl.check_health() + + async def record_routing_stats(self) -> Dict[str, Any]: + return await self._replica_impl.record_routing_stats() + + async def reconfigure( + self, deployment_config, rank: ReplicaRank, route_prefix: Optional[str] = None + ) -> ReplicaMetadata: + await self._replica_impl.reconfigure(deployment_config, rank, route_prefix) + return self._replica_impl.get_metadata() + + def _preprocess_request_args( + self, + pickled_request_metadata: bytes, + request_args: Tuple[Any], + ) -> Tuple[RequestMetadata, Tuple[Any]]: + request_metadata = pickle.loads(pickled_request_metadata) + if request_metadata.is_http_request or request_metadata.is_grpc_request: + request_args = (pickle.loads(request_args[0]),) + + return request_metadata, request_args + + async def handle_request( + self, + pickled_request_metadata: bytes, + *request_args, + **request_kwargs, + ) -> Tuple[bytes, Any]: + """Entrypoint for `stream=False` calls.""" + request_metadata, request_args = self._preprocess_request_args( + pickled_request_metadata, request_args + ) + result = await self._replica_impl.handle_request( + request_metadata, *request_args, **request_kwargs + ) + if request_metadata.is_grpc_request: + result = (request_metadata.grpc_context, result.SerializeToString()) + + return result + + async def handle_request_streaming( + self, + pickled_request_metadata: bytes, + *request_args, + **request_kwargs, + ) -> AsyncGenerator[Any, None]: + """Generator that is the entrypoint for all `stream=True` handle calls.""" + request_metadata, request_args = self._preprocess_request_args( + pickled_request_metadata, request_args + ) + async for result in self._replica_impl.handle_request_streaming( + request_metadata, *request_args, **request_kwargs + ): + if request_metadata.is_grpc_request: + result = (request_metadata.grpc_context, result.SerializeToString()) + + yield result + + async def handle_request_with_rejection( + self, + pickled_request_metadata: bytes, + *request_args, + **request_kwargs, + ) -> AsyncGenerator[Any, None]: + """Entrypoint for all requests with strict max_ongoing_requests enforcement. + + The first response from this generator is always a system message indicating + if the request was accepted (the replica has capacity for the request) or + rejected (the replica is already at max_ongoing_requests). + + For non-streaming requests, there will only be one more message, the unary + result of the user request handler. + + For streaming requests, the subsequent messages will be the results of the + user request handler (which must be a generator). + """ + request_metadata, request_args = self._preprocess_request_args( + pickled_request_metadata, request_args + ) + async for result in self._replica_impl.handle_request_with_rejection( + request_metadata, *request_args, **request_kwargs + ): + if isinstance(result, ReplicaQueueLengthInfo): + yield pickle.dumps(result) + else: + if request_metadata.is_grpc_request: + result = (request_metadata.grpc_context, result.SerializeToString()) + + yield result + + async def handle_request_from_java( + self, + proto_request_metadata: bytes, + *request_args, + **request_kwargs, + ) -> Any: + from ray.serve.generated.serve_pb2 import ( + RequestMetadata as RequestMetadataProto, + ) + + proto = RequestMetadataProto.FromString(proto_request_metadata) + request_metadata: RequestMetadata = RequestMetadata( + request_id=proto.request_id, + internal_request_id=proto.internal_request_id, + call_method=proto.call_method, + multiplexed_model_id=proto.multiplexed_model_id, + route=proto.route, + ) + return await self._replica_impl.handle_request( + request_metadata, *request_args, **request_kwargs + ) + + async def perform_graceful_shutdown(self): + await self._replica_impl.perform_graceful_shutdown() + + +@dataclass +class UserMethodInfo: + """Wrapper for a user method and its relevant metadata.""" + + callable: Callable + name: str + is_asgi_app: bool + takes_any_args: bool + takes_grpc_context_kwarg: bool + + @classmethod + def from_callable(cls, c: Callable, *, is_asgi_app: bool) -> "UserMethodInfo": + params = inspect.signature(c).parameters + return cls( + callable=c, + name=c.__name__, + is_asgi_app=is_asgi_app, + takes_any_args=len(params) > 0, + takes_grpc_context_kwarg=GRPC_CONTEXT_ARG_NAME in params, + ) + + +class UserCallableWrapper: + """Wraps a user-provided callable that is used to handle requests to a replica.""" + + service_unavailable_exceptions = (BackPressureError, DeploymentUnavailableError) + + def __init__( + self, + deployment_def: Callable, + init_args: Tuple, + init_kwargs: Dict, + *, + deployment_id: DeploymentID, + run_sync_methods_in_threadpool: bool, + run_user_code_in_separate_thread: bool, + local_testing_mode: bool, + deployment_config: DeploymentConfig, + ): + if not (inspect.isfunction(deployment_def) or inspect.isclass(deployment_def)): + raise TypeError( + "deployment_def must be a function or class. Instead, its type was " + f"{type(deployment_def)}." + ) + + self._deployment_def = deployment_def + self._init_args = init_args + self._init_kwargs = init_kwargs + self._is_function = inspect.isfunction(deployment_def) + self._deployment_id = deployment_id + self._local_testing_mode = local_testing_mode + self._destructor_called = False + self._run_sync_methods_in_threadpool = run_sync_methods_in_threadpool + self._run_user_code_in_separate_thread = run_user_code_in_separate_thread + self._warned_about_sync_method_change = False + self._cached_user_method_info: Dict[str, UserMethodInfo] = {} + # This is for performance optimization https://docs.python.org/3/howto/logging.html#optimization + self._is_enabled_for_debug = logger.isEnabledFor(logging.DEBUG) + # Will be populated in `initialize_callable`. + self._callable = None + self._deployment_config = deployment_config + + if self._run_user_code_in_separate_thread: + # All interactions with user code run on this loop to avoid blocking the + # replica's main event loop. + self._user_code_event_loop: asyncio.AbstractEventLoop = ( + asyncio.new_event_loop() + ) + + def _run_user_code_event_loop(): + # Required so that calls to get the current running event loop work + # properly in user code. + asyncio.set_event_loop(self._user_code_event_loop) + self._user_code_event_loop.run_forever() + + self._user_code_event_loop_thread = threading.Thread( + daemon=True, + target=_run_user_code_event_loop, + ) + self._user_code_event_loop_thread.start() + else: + self._user_code_event_loop = asyncio.get_running_loop() + + @property + def event_loop(self) -> asyncio.AbstractEventLoop: + return self._user_code_event_loop + + def _run_user_code(f: Callable) -> Callable: + """Decorator to run a coroutine method on the user code event loop. + + The method will be modified to be a sync function that returns a + `asyncio.Future` if user code is running in a separate event loop. + Otherwise, it will return the coroutine directly. + """ + assert inspect.iscoroutinefunction( + f + ), "_run_user_code can only be used on coroutine functions." + + @functools.wraps(f) + def wrapper(self, *args, **kwargs) -> Any: + coro = f(self, *args, **kwargs) + if self._run_user_code_in_separate_thread: + fut = asyncio.run_coroutine_threadsafe(coro, self._user_code_event_loop) + if self._local_testing_mode: + return fut + + return asyncio.wrap_future(fut) + else: + return coro + + return wrapper + + @_run_user_code + async def set_sync_method_threadpool_limit(self, limit: int): + # NOTE(edoakes): the limit is thread local, so this must + # be run on the user code event loop. + to_thread.current_default_thread_limiter().total_tokens = limit + + def get_user_method_info(self, method_name: str) -> UserMethodInfo: + """Get UserMethodInfo for the provided call method name. + + This method is cached to avoid repeated expensive calls to `inspect.signature`. + """ + if method_name in self._cached_user_method_info: + return self._cached_user_method_info[method_name] + + if self._is_function: + user_method = self._callable + elif hasattr(self._callable, method_name): + user_method = getattr(self._callable, method_name) + else: + # Filter to methods that don't start with '__' prefix. + def callable_method_filter(attr): + if attr.startswith("__"): + return False + elif not callable(getattr(self._callable, attr)): + return False + + return True + + methods = list(filter(callable_method_filter, dir(self._callable))) + raise RayServeException( + f"Tried to call a method '{method_name}' " + "that does not exist. Available methods: " + f"{methods}." + ) + + info = UserMethodInfo.from_callable( + user_method, + is_asgi_app=isinstance(self._callable, ASGIAppReplicaWrapper), + ) + self._cached_user_method_info[method_name] = info + return info + + async def _send_user_result_over_asgi( + self, + result: Any, + asgi_args: ASGIArgs, + ): + """Handle the result from user code and send it over the ASGI interface. + + If the result is already a Response type, it is sent directly. Otherwise, it + is converted to a custom Response type that handles serialization for + common Python objects. + """ + scope, receive, send = asgi_args.to_args_tuple() + if isinstance(result, starlette.responses.Response): + await result(scope, receive, send) + else: + await Response(result).send(scope, receive, send) + + async def _call_func_or_gen( + self, + callable: Callable, + *, + args: Optional[Tuple[Any]] = None, + kwargs: Optional[Dict[str, Any]] = None, + is_streaming: bool = False, + generator_result_callback: Optional[Callable] = None, + run_sync_methods_in_threadpool_override: Optional[bool] = None, + ) -> Tuple[Any, bool]: + """Call the callable with the provided arguments. + + This is a convenience wrapper that will work for `def`, `async def`, + generator, and async generator functions. + + Returns the result and a boolean indicating if the result was a sync generator + that has already been consumed. + """ + sync_gen_consumed = False + args = args if args is not None else tuple() + kwargs = kwargs if kwargs is not None else dict() + run_sync_in_threadpool = ( + self._run_sync_methods_in_threadpool + if run_sync_methods_in_threadpool_override is None + else run_sync_methods_in_threadpool_override + ) + is_sync_method = ( + inspect.isfunction(callable) or inspect.ismethod(callable) + ) and not ( + inspect.iscoroutinefunction(callable) + or inspect.isasyncgenfunction(callable) + ) + + if is_sync_method and run_sync_in_threadpool: + is_generator = inspect.isgeneratorfunction(callable) + if is_generator: + sync_gen_consumed = True + if not is_streaming: + # TODO(edoakes): make this check less redundant with the one in + # _handle_user_method_result. + raise TypeError( + f"Method '{callable.__name__}' returned a generator. " + "You must use `handle.options(stream=True)` to call " + "generators on a deployment." + ) + + def run_callable(): + result = callable(*args, **kwargs) + if is_generator: + for r in result: + generator_result_callback(r) + + result = None + + return result + + # NOTE(edoakes): we use anyio.to_thread here because it's what Starlette + # uses (and therefore FastAPI too). The max size of the threadpool is + # set to max_ongoing_requests in the replica wrapper. + # anyio.to_thread propagates ContextVars to the worker thread automatically. + result = await to_thread.run_sync(run_callable) + else: + if ( + is_sync_method + and not self._warned_about_sync_method_change + and run_sync_methods_in_threadpool_override is None + ): + self._warned_about_sync_method_change = True + warnings.warn( + RAY_SERVE_RUN_SYNC_IN_THREADPOOL_WARNING.format( + method_name=callable.__name__, + ) + ) + + result = callable(*args, **kwargs) + if inspect.iscoroutine(result): + result = await result + + return result, sync_gen_consumed + + @property + def user_callable(self) -> Optional[Callable]: + return self._callable + + async def _initialize_asgi_callable(self) -> None: + self._callable: ASGIAppReplicaWrapper + + app: Starlette = self._callable.app + + # The reason we need to do this is because BackPressureError is a serve internal exception + # and FastAPI doesn't know how to handle it, so it treats it as a 500 error. + # With same reasoning, we are not handling TimeoutError because it's a generic exception + # the FastAPI knows how to handle. See https://www.starlette.io/exceptions/ + def handle_exception(_: Request, exc: Exception): + return self.handle_exception(exc) + + for exc in self.service_unavailable_exceptions: + app.add_exception_handler(exc, handle_exception) + + await self._callable._run_asgi_lifespan_startup() + + @_run_user_code + async def initialize_callable(self) -> Optional[ASGIApp]: + """Initialize the user callable. + + If the callable is an ASGI app wrapper (e.g., using @serve.ingress), returns + the ASGI app object, which may be used *read only* by the caller. + """ + if self._callable is not None: + raise RuntimeError("initialize_callable should only be called once.") + + # This closure initializes user code and finalizes replica + # startup. By splitting the initialization step like this, + # we can already access this actor before the user code + # has finished initializing. + # The supervising state manager can then wait + # for allocation of this replica by using the `is_allocated` + # method. After that, it calls `reconfigure` to trigger + # user code initialization. + logger.info( + "Started initializing replica.", + extra={"log_to_stderr": False}, + ) + + if self._is_function: + self._callable = self._deployment_def + else: + # This allows deployments to define an async __init__ + # method (mostly used for testing). + self._callable = self._deployment_def.__new__(self._deployment_def) + await self._call_func_or_gen( + self._callable.__init__, + args=self._init_args, + kwargs=self._init_kwargs, + # Always run the constructor on the main user code thread. + run_sync_methods_in_threadpool_override=False, + ) + + if isinstance(self._callable, ASGIAppReplicaWrapper): + await self._initialize_asgi_callable() + + if isinstance(self._callable, TaskConsumerWrapper): + self._callable.initialize_callable( + self._deployment_config.max_ongoing_requests + ) + ServeUsageTag.NUM_REPLICAS_USING_ASYNCHRONOUS_INFERENCE.record("1") + + self._user_health_check = getattr(self._callable, HEALTH_CHECK_METHOD, None) + self._user_record_routing_stats = getattr( + self._callable, REQUEST_ROUTING_STATS_METHOD, None + ) + self._user_autoscaling_stats = getattr( + self._callable, "record_autoscaling_stats", None + ) + + logger.info( + "Finished initializing replica.", + extra={"log_to_stderr": False}, + ) + + return ( + self._callable.app + if isinstance(self._callable, ASGIAppReplicaWrapper) + else None + ) + + def _raise_if_not_initialized(self, method_name: str): + if self._callable is None: + raise RuntimeError( + f"`initialize_callable` must be called before `{method_name}`." + ) + + def call_user_health_check(self) -> Optional[concurrent.futures.Future]: + self._raise_if_not_initialized("call_user_health_check") + + # If the user provided a health check, call it on the user code thread. If user + # code blocks the event loop the health check may time out. + # + # To avoid this issue for basic cases without a user-defined health check, skip + # interacting with the user callable entirely. + if self._user_health_check is not None: + return self._call_user_health_check() + + return None + + def call_user_record_routing_stats(self) -> Optional[concurrent.futures.Future]: + self._raise_if_not_initialized("call_user_record_routing_stats") + + if self._user_record_routing_stats is not None: + return self._call_user_record_routing_stats() + + return None + + def call_record_autoscaling_stats(self) -> Optional[concurrent.futures.Future]: + self._raise_if_not_initialized("call_record_autoscaling_stats") + + if self._user_autoscaling_stats is not None: + return self._call_user_autoscaling_stats() + + return None + + @_run_user_code + async def _call_user_health_check(self): + await self._call_func_or_gen(self._user_health_check) + + @_run_user_code + async def _call_user_record_routing_stats(self) -> Dict[str, Any]: + result, _ = await self._call_func_or_gen(self._user_record_routing_stats) + return result + + @_run_user_code + async def _call_user_autoscaling_stats(self) -> Dict[str, Union[int, float]]: + result, _ = await self._call_func_or_gen(self._user_autoscaling_stats) + return result + + @_run_user_code + async def call_reconfigure(self, user_config: Optional[Any], rank: ReplicaRank): + self._raise_if_not_initialized("call_reconfigure") + + # NOTE(edoakes): there is the possibility of a race condition in user code if + # they don't have any form of concurrency control between `reconfigure` and + # other methods. See https://github.com/ray-project/ray/pull/42159. + + # NOTE(abrar): The only way to subscribe to rank changes is to provide some user config. + # We can relax this in the future as more use cases arise for rank. I am reluctant to + # introduce behavior change for a feature we might not need. + user_subscribed_to_rank = False + if not self._is_function and hasattr(self._callable, RECONFIGURE_METHOD): + reconfigure_method = getattr(self._callable, RECONFIGURE_METHOD) + params = inspect.signature(reconfigure_method).parameters + user_subscribed_to_rank = "rank" in params + if user_config is not None or user_subscribed_to_rank: + if self._is_function: + raise ValueError( + "deployment_def must be a class to use user_config or rank" + ) + elif not hasattr(self._callable, RECONFIGURE_METHOD): + raise RayServeException( + "user_config or rank specified but deployment " + + self._deployment_id + + " missing " + + RECONFIGURE_METHOD + + " method" + ) + kwargs = {} + if user_subscribed_to_rank: + # For backwards compatibility, only pass rank if it is an argument to the reconfigure method. + kwargs["rank"] = rank + await self._call_func_or_gen( + getattr(self._callable, RECONFIGURE_METHOD), + args=(user_config,), + kwargs=kwargs, + ) + + async def _handle_user_method_result( + self, + result: Any, + user_method_info: UserMethodInfo, + *, + is_streaming: bool, + is_http_request: bool, + sync_gen_consumed: bool, + generator_result_callback: Optional[Callable], + asgi_args: Optional[ASGIArgs], + ) -> Any: + """Postprocess the result of a user method. + + User methods can be regular unary functions or return a sync or async generator. + This method will raise an exception if the result is not of the expected type + (e.g., non-generator for streaming requests or generator for unary requests). + + Generator outputs will be written to the `generator_result_callback`. + + Note that HTTP requests are an exception: they are *always* streaming requests, + but for ASGI apps (like FastAPI), the actual method will be a regular function + implementing the ASGI `__call__` protocol. + """ + result_is_gen = inspect.isgenerator(result) + result_is_async_gen = inspect.isasyncgen(result) + if is_streaming: + if result_is_gen: + for r in result: + generator_result_callback(r) + elif result_is_async_gen: + async for r in result: + generator_result_callback(r) + elif is_http_request and not user_method_info.is_asgi_app: + # For the FastAPI codepath, the response has already been sent over + # ASGI, but for the vanilla deployment codepath we need to send it. + await self._send_user_result_over_asgi(result, asgi_args) + elif not is_http_request and not sync_gen_consumed: + # If a unary method is called with stream=True for anything EXCEPT + # an HTTP request, raise an error. + # HTTP requests are always streaming regardless of if the method + # returns a generator, because it's provided the result queue as its + # ASGI `send` interface to stream back results. + raise TypeError( + f"Called method '{user_method_info.name}' with " + "`handle.options(stream=True)` but it did not return a " + "generator." + ) + else: + assert ( + not is_http_request + ), "All HTTP requests go through the streaming codepath." + + if result_is_gen or result_is_async_gen: + raise TypeError( + f"Method '{user_method_info.name}' returned a generator. " + "You must use `handle.options(stream=True)` to call " + "generators on a deployment." + ) + + return result + + async def call_http_entrypoint( + self, + request_metadata: RequestMetadata, + status_code_callback: StatusCodeCallback, + scope: Scope, + receive: Receive, + ) -> Any: + result_queue = MessageQueue() + user_method_info = self.get_user_method_info(request_metadata.call_method) + + if self._run_user_code_in_separate_thread: + # `asyncio.Event`s are not thread safe, so `call_soon_threadsafe` must be + # used to interact with the result queue from the user callable thread. + system_event_loop = asyncio.get_running_loop() + + async def enqueue(item: Any): + system_event_loop.call_soon_threadsafe(result_queue.put_nowait, item) + + call_future = self._call_http_entrypoint( + user_method_info, scope, receive, enqueue + ) + else: + + async def enqueue(item: Any): + result_queue.put_nowait(item) + + call_future = asyncio.create_task( + self._call_http_entrypoint(user_method_info, scope, receive, enqueue) + ) + + first_message_peeked = False + async for messages in result_queue.fetch_messages_from_queue(call_future): + # HTTP (ASGI) messages are only consumed by the proxy so batch them + # and use vanilla pickle (we know it's safe because these messages + # only contain primitive Python types). + # Peek the first ASGI message to determine the status code. + if not first_message_peeked: + msg = messages[0] + first_message_peeked = True + if msg["type"] == "http.response.start": + # HTTP responses begin with exactly one + # "http.response.start" message containing the "status" + # field. Other response types like WebSockets may not. + status_code_callback(str(msg["status"])) + + yield messages + + @_run_user_code + async def _call_http_entrypoint( + self, + user_method_info: UserMethodInfo, + scope: Scope, + receive: Receive, + send: Send, + ) -> Any: + """Call an HTTP entrypoint. + + `send` is used to communicate the results of streaming responses. + + Raises any exception raised by the user code so it can be propagated as a + `RayTaskError`. + """ + self._raise_if_not_initialized("_call_http_entrypoint") + + if self._is_enabled_for_debug: + logger.debug( + f"Started executing request to method '{user_method_info.name}'.", + extra={"log_to_stderr": False, "serve_access_log": True}, + ) + + if user_method_info.is_asgi_app: + request_args = (scope, receive, send) + elif not user_method_info.takes_any_args: + # Edge case to support empty HTTP handlers: don't pass the Request + # argument if the callable has no parameters. + request_args = tuple() + else: + # Non-FastAPI HTTP handlers take only the starlette `Request`. + request_args = (starlette.requests.Request(scope, receive, send),) + + receive_task = None + try: + if hasattr(receive, "fetch_until_disconnect"): + receive_task = asyncio.create_task(receive.fetch_until_disconnect()) + + result, sync_gen_consumed = await self._call_func_or_gen( + user_method_info.callable, + args=request_args, + kwargs={}, + is_streaming=True, + generator_result_callback=send, + ) + final_result = await self._handle_user_method_result( + result, + user_method_info, + is_streaming=True, + is_http_request=True, + sync_gen_consumed=sync_gen_consumed, + generator_result_callback=send, + asgi_args=ASGIArgs(scope, receive, send), + ) + + if receive_task is not None and not receive_task.done(): + receive_task.cancel() + + return final_result + except Exception as e: + if not user_method_info.is_asgi_app: + response = self.handle_exception(e) + await self._send_user_result_over_asgi( + response, ASGIArgs(scope, receive, send) + ) + + if receive_task is not None and not receive_task.done(): + receive_task.cancel() + + raise + except asyncio.CancelledError: + if receive_task is not None and not receive_task.done(): + # Do NOT cancel the receive task if the request has been + # cancelled, but the call is a batched call. This is + # because we cannot guarantee cancelling the batched + # call, so in the case that the call continues executing + # we should continue fetching data from the client. + if not hasattr(user_method_info.callable, "set_max_batch_size"): + receive_task.cancel() + + raise + + async def call_user_generator( + self, + request_metadata: RequestMetadata, + request_args: Tuple[Any], + request_kwargs: Dict[str, Any], + ) -> AsyncGenerator[Any, None]: + """Calls a user method for a streaming call and yields its results. + + The user method is called in an asyncio `Task` and places its results on a + `result_queue`. This method pulls and yields from the `result_queue`. + """ + if not self._run_user_code_in_separate_thread: + gen = await self._call_user_generator( + request_metadata, request_args, request_kwargs + ) + async for result in gen: + yield result + else: + result_queue = MessageQueue() + + # `asyncio.Event`s are not thread safe, so `call_soon_threadsafe` must be + # used to interact with the result queue from the user callable thread. + system_event_loop = asyncio.get_running_loop() + + def _enqueue_thread_safe(item: Any): + system_event_loop.call_soon_threadsafe(result_queue.put_nowait, item) + + call_future = self._call_user_generator( + request_metadata, + request_args, + request_kwargs, + enqueue=_enqueue_thread_safe, + ) + + async for messages in result_queue.fetch_messages_from_queue(call_future): + for msg in messages: + yield msg + + @_run_user_code + async def _call_user_generator( + self, + request_metadata: RequestMetadata, + request_args: Tuple[Any], + request_kwargs: Dict[str, Any], + *, + enqueue: Optional[Callable] = None, + ) -> Optional[AsyncGenerator[Any, None]]: + """Call a user generator. + + The `generator_result_callback` is used to communicate the results of generator + methods. + + Raises any exception raised by the user code so it can be propagated as a + `RayTaskError`. + """ + self._raise_if_not_initialized("_call_user_generator") + + request_args = request_args if request_args is not None else tuple() + request_kwargs = request_kwargs if request_kwargs is not None else dict() + + user_method_info = self.get_user_method_info(request_metadata.call_method) + callable = user_method_info.callable + is_sync_method = ( + inspect.isfunction(callable) or inspect.ismethod(callable) + ) and not ( + inspect.iscoroutinefunction(callable) + or inspect.isasyncgenfunction(callable) + ) + + if self._is_enabled_for_debug: + logger.debug( + f"Started executing request to method '{user_method_info.name}'.", + extra={"log_to_stderr": False, "serve_access_log": True}, + ) + + async def _call_generator_async() -> AsyncGenerator[Any, None]: + gen = callable(*request_args, **request_kwargs) + if inspect.iscoroutine(gen): + gen = await gen + + if inspect.isgenerator(gen): + for result in gen: + yield result + elif inspect.isasyncgen(gen): + async for result in gen: + yield result + else: + raise TypeError( + f"Called method '{user_method_info.name}' with " + "`handle.options(stream=True)` but it did not return a generator." + ) + + def _call_generator_sync(): + gen = callable(*request_args, **request_kwargs) + if inspect.isgenerator(gen): + for result in gen: + enqueue(result) + else: + raise TypeError( + f"Called method '{user_method_info.name}' with " + "`handle.options(stream=True)` but it did not return a generator." + ) + + if enqueue and is_sync_method and self._run_sync_methods_in_threadpool: + await to_thread.run_sync(_call_generator_sync) + elif enqueue: + + async def gen_coro_wrapper(): + async for result in _call_generator_async(): + enqueue(result) + + await gen_coro_wrapper() + else: + return _call_generator_async() + + @_run_user_code + async def call_user_method( + self, + request_metadata: RequestMetadata, + request_args: Tuple[Any], + request_kwargs: Dict[str, Any], + ) -> Any: + """Call a (unary) user method. + + Raises any exception raised by the user code so it can be propagated as a + `RayTaskError`. + """ + self._raise_if_not_initialized("call_user_method") + + if self._is_enabled_for_debug: + logger.debug( + f"Started executing request to method '{request_metadata.call_method}'.", + extra={"log_to_stderr": False, "serve_access_log": True}, + ) + + user_method_info = self.get_user_method_info(request_metadata.call_method) + result, _ = await self._call_func_or_gen( + user_method_info.callable, + args=request_args, + kwargs=request_kwargs, + is_streaming=False, + ) + if inspect.isgenerator(result) or inspect.isasyncgen(result): + raise TypeError( + f"Method '{user_method_info.name}' returned a generator. " + "You must use `handle.options(stream=True)` to call " + "generators on a deployment." + ) + return result + + def handle_exception(self, exc: Exception): + if isinstance(exc, self.service_unavailable_exceptions): + return starlette.responses.Response(exc.message, status_code=503) + else: + return starlette.responses.Response( + "Internal Server Error", status_code=500 + ) + + @_run_user_code + async def call_destructor(self): + """Explicitly call the `__del__` method of the user callable. + + Calling this multiple times has no effect; only the first call will + actually call the destructor. + """ + if self._callable is None: + logger.debug( + "This replica has not yet started running user code. " + "Skipping __del__." + ) + return + + # Only run the destructor once. This is safe because there is no `await` between + # checking the flag here and flipping it to `True` below. + if self._destructor_called: + return + + self._destructor_called = True + try: + if hasattr(self._callable, "__del__"): + # Make sure to accept `async def __del__(self)` as well. + await self._call_func_or_gen( + self._callable.__del__, + # Always run the destructor on the main user callable thread. + run_sync_methods_in_threadpool_override=False, + ) + + if hasattr(self._callable, "__serve_multiplex_wrapper"): + await getattr(self._callable, "__serve_multiplex_wrapper").shutdown() + + except Exception as e: + logger.exception(f"Exception during graceful shutdown of replica: {e}") diff --git a/lib/python3.12/site-packages/ray/serve/_private/replica_result.py b/lib/python3.12/site-packages/ray/serve/_private/replica_result.py new file mode 100644 index 0000000000000000000000000000000000000000..9deaf00ef7743f5cab20debbc906b1a4e8e3a1f3 --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/_private/replica_result.py @@ -0,0 +1,248 @@ +import asyncio +import inspect +import logging +import pickle +import threading +import time +from abc import ABC, abstractmethod +from functools import wraps +from typing import Callable, Coroutine, Optional, Union + +import ray +from ray.exceptions import TaskCancelledError +from ray.serve._private.common import ReplicaQueueLengthInfo, RequestMetadata +from ray.serve._private.constants import SERVE_LOGGER_NAME +from ray.serve._private.utils import calculate_remaining_timeout, generate_request_id +from ray.serve.exceptions import RequestCancelledError + +logger = logging.getLogger(SERVE_LOGGER_NAME) + + +class ReplicaResult(ABC): + @abstractmethod + async def get_rejection_response(self) -> Optional[ReplicaQueueLengthInfo]: + raise NotImplementedError + + @abstractmethod + def get(self, timeout_s: Optional[float]): + raise NotImplementedError + + @abstractmethod + async def get_async(self): + raise NotImplementedError + + @abstractmethod + def __next__(self): + raise NotImplementedError + + @abstractmethod + async def __anext__(self): + raise NotImplementedError + + @abstractmethod + def add_done_callback(self, callback: Callable): + raise NotImplementedError + + @abstractmethod + def cancel(self): + raise NotImplementedError + + @abstractmethod + def to_object_ref(self, timeout_s: Optional[float]) -> ray.ObjectRef: + raise NotImplementedError + + @abstractmethod + async def to_object_ref_async(self) -> ray.ObjectRef: + raise NotImplementedError + + @abstractmethod + def to_object_ref_gen(self) -> ray.ObjectRefGenerator: + # NOTE(edoakes): there is only a sync version of this method because it + # does not block like `to_object_ref` (so there's also no timeout argument). + raise NotImplementedError + + +class ActorReplicaResult(ReplicaResult): + def __init__( + self, + obj_ref_or_gen: Union[ray.ObjectRef, ray.ObjectRefGenerator], + metadata: RequestMetadata, + *, + with_rejection: bool = False, + ): + self._obj_ref: Optional[ray.ObjectRef] = None + self._obj_ref_gen: Optional[ray.ObjectRefGenerator] = None + self._is_streaming: bool = metadata.is_streaming + self._request_id: str = metadata.request_id + self._object_ref_or_gen_sync_lock = threading.Lock() + self._lazy_object_ref_or_gen_asyncio_lock = None + self._with_rejection = with_rejection + self._rejection_response = None + + if isinstance(obj_ref_or_gen, ray.ObjectRefGenerator): + self._obj_ref_gen = obj_ref_or_gen + else: + self._obj_ref = obj_ref_or_gen + + if self._is_streaming: + assert ( + self._obj_ref_gen is not None + ), "An ObjectRefGenerator must be passed for streaming requests." + + request_context = ray.serve.context._get_serve_request_context() + if request_context.cancel_on_parent_request_cancel: + # Keep track of in-flight requests. + self._response_id = generate_request_id() + ray.serve.context._add_in_flight_request( + request_context._internal_request_id, self._response_id, self + ) + self.add_done_callback( + lambda _: ray.serve.context._remove_in_flight_request( + request_context._internal_request_id, self._response_id + ) + ) + + @property + def _object_ref_or_gen_asyncio_lock(self) -> asyncio.Lock: + """Lazy `asyncio.Lock` object.""" + if self._lazy_object_ref_or_gen_asyncio_lock is None: + self._lazy_object_ref_or_gen_asyncio_lock = asyncio.Lock() + + return self._lazy_object_ref_or_gen_asyncio_lock + + def _process_response(f: Union[Callable, Coroutine]): + @wraps(f) + def wrapper(self, *args, **kwargs): + try: + return f(self, *args, **kwargs) + except ray.exceptions.TaskCancelledError: + raise RequestCancelledError(self._request_id) + + @wraps(f) + async def async_wrapper(self, *args, **kwargs): + try: + return await f(self, *args, **kwargs) + except ray.exceptions.TaskCancelledError: + raise asyncio.CancelledError() + + if inspect.iscoroutinefunction(f): + return async_wrapper + else: + return wrapper + + @_process_response + async def get_rejection_response(self) -> Optional[ReplicaQueueLengthInfo]: + """Get the queue length info from the replica to handle rejection.""" + assert ( + self._with_rejection and self._obj_ref_gen is not None + ), "get_rejection_response() can only be called when request rejection is enabled." + + try: + if self._rejection_response is None: + response = await (await self._obj_ref_gen.__anext__()) + self._rejection_response = pickle.loads(response) + + return self._rejection_response + except asyncio.CancelledError as e: + # HTTP client disconnected or request was explicitly canceled. + logger.info( + "Cancelling request that has already been assigned to a replica." + ) + self.cancel() + raise e from None + except TaskCancelledError: + raise asyncio.CancelledError() + + @_process_response + def get(self, timeout_s: Optional[float]): + assert ( + not self._is_streaming + ), "get() can only be called on a unary ActorReplicaResult." + + start_time_s = time.time() + object_ref = self.to_object_ref(timeout_s=timeout_s) + remaining_timeout_s = calculate_remaining_timeout( + timeout_s=timeout_s, + start_time_s=start_time_s, + curr_time_s=time.time(), + ) + return ray.get(object_ref, timeout=remaining_timeout_s) + + @_process_response + async def get_async(self): + assert ( + not self._is_streaming + ), "get_async() can only be called on a unary ActorReplicaResult." + + return await (await self.to_object_ref_async()) + + @_process_response + def __next__(self): + assert ( + self._is_streaming + ), "next() can only be called on a streaming ActorReplicaResult." + + next_obj_ref = self._obj_ref_gen.__next__() + return ray.get(next_obj_ref) + + @_process_response + async def __anext__(self): + assert ( + self._is_streaming + ), "__anext__() can only be called on a streaming ActorReplicaResult." + + next_obj_ref = await self._obj_ref_gen.__anext__() + return await next_obj_ref + + def add_done_callback(self, callback: Callable): + if self._obj_ref_gen is not None: + self._obj_ref_gen.completed()._on_completed(callback) + else: + self._obj_ref._on_completed(callback) + + def cancel(self): + if self._obj_ref_gen is not None: + ray.cancel(self._obj_ref_gen) + else: + ray.cancel(self._obj_ref) + + def to_object_ref(self, *, timeout_s: Optional[float] = None) -> ray.ObjectRef: + assert ( + not self._is_streaming + ), "to_object_ref can only be called on a unary ReplicaActorResult." + + # NOTE(edoakes): this section needs to be guarded with a lock and the resulting + # object ref cached in order to avoid calling `__next__()` to + # resolve to the underlying object ref more than once. + # See: https://github.com/ray-project/ray/issues/43879. + with self._object_ref_or_gen_sync_lock: + if self._obj_ref is None: + obj_ref = self._obj_ref_gen._next_sync(timeout_s=timeout_s) + if obj_ref.is_nil(): + raise TimeoutError("Timed out resolving to ObjectRef.") + + self._obj_ref = obj_ref + + return self._obj_ref + + async def to_object_ref_async(self) -> ray.ObjectRef: + assert ( + not self._is_streaming + ), "to_object_ref_async can only be called on a unary ReplicaActorResult." + + # NOTE(edoakes): this section needs to be guarded with a lock and the resulting + # object ref cached in order to avoid calling `__anext__()` to + # resolve to the underlying object ref more than once. + # See: https://github.com/ray-project/ray/issues/43879. + async with self._object_ref_or_gen_asyncio_lock: + if self._obj_ref is None: + self._obj_ref = await self._obj_ref_gen.__anext__() + + return self._obj_ref + + def to_object_ref_gen(self) -> ray.ObjectRefGenerator: + assert ( + self._is_streaming + ), "to_object_ref_gen can only be called on a streaming ReplicaActorResult." + + return self._obj_ref_gen diff --git a/lib/python3.12/site-packages/ray/serve/_private/request_router/__init__.py b/lib/python3.12/site-packages/ray/serve/_private/request_router/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..b4b7616810775a7b1857bc1247a3dc7126990572 --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/_private/request_router/__init__.py @@ -0,0 +1,10 @@ +from ray.serve._private.request_router.common import PendingRequest # noqa: F401 +from ray.serve._private.request_router.pow_2_router import ( # noqa: F401 + PowerOfTwoChoicesRequestRouter, +) +from ray.serve._private.request_router.replica_wrapper import ( # noqa: F401 + RunningReplica, +) +from ray.serve._private.request_router.request_router import ( # noqa: F401 + RequestRouter, +) diff --git a/lib/python3.12/site-packages/ray/serve/_private/request_router/__pycache__/__init__.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/_private/request_router/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b5bf390cd4c74ef77c2c9922aa28febc62b6a834 Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/_private/request_router/__pycache__/__init__.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/_private/request_router/__pycache__/common.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/_private/request_router/__pycache__/common.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..53bab7bdda5c0f8830853d01b719f4cdfaa5332e Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/_private/request_router/__pycache__/common.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/_private/request_router/__pycache__/pow_2_router.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/_private/request_router/__pycache__/pow_2_router.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..798961420029e239bfba2165bb7a10c68565e92a Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/_private/request_router/__pycache__/pow_2_router.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/_private/request_router/__pycache__/replica_wrapper.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/_private/request_router/__pycache__/replica_wrapper.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c38abebbb242eb82c0751754e92cba966b3d4bb5 Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/_private/request_router/__pycache__/replica_wrapper.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/_private/request_router/__pycache__/request_router.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/_private/request_router/__pycache__/request_router.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c897f7b678588936ad8b711c3afe1be74ddd389a Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/_private/request_router/__pycache__/request_router.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/_private/request_router/common.py b/lib/python3.12/site-packages/ray/serve/_private/request_router/common.py new file mode 100644 index 0000000000000000000000000000000000000000..b373f47f1528774b6f0fb807ccac906d3d60f5cf --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/_private/request_router/common.py @@ -0,0 +1,107 @@ +import asyncio +import logging +import time +from dataclasses import dataclass, field +from typing import Any, Callable, Dict, List, Optional, Set + +from ray.serve._private.common import ReplicaID, RequestMetadata +from ray.serve._private.constants import ( + RAY_SERVE_QUEUE_LENGTH_CACHE_TIMEOUT_S, + SERVE_LOGGER_NAME, +) +from ray.util.annotations import PublicAPI + +logger = logging.getLogger(SERVE_LOGGER_NAME) + + +@dataclass() +class RequestRoutingContext: + multiplexed_start_matching_time: Optional[float] = None + tried_fewest_multiplexed_models: bool = False + tried_first_multiplexed_models: bool = False + tried_same_node: bool = False + tried_same_az: bool = False + should_backoff: bool = False + + +@PublicAPI(stability="alpha") +@dataclass +class PendingRequest: + """A request that is pending execution by a replica.""" + + args: List[Any] + """Positional arguments for the request.""" + + kwargs: Dict[Any, Any] + """Keyword arguments for the request.""" + + metadata: RequestMetadata + """Metadata for the request, including request ID and whether it's streaming.""" + + created_at: float = field(default_factory=lambda: time.time()) + """Timestamp when the request was created.""" + + future: asyncio.Future = field(default_factory=lambda: asyncio.Future()) + """An asyncio Future that will be set when the request is routed.""" + + routing_context: RequestRoutingContext = field( + default_factory=RequestRoutingContext + ) + """Context for request routing, used to track routing attempts and backoff.""" + + resolved: bool = False + """Whether the arguments have been resolved.""" + + def reset_future(self): + """Reset the `asyncio.Future`, must be called if this request is re-used.""" + self.future = asyncio.Future() + + +@dataclass(frozen=True) +class ReplicaQueueLengthCacheEntry: + queue_len: int + timestamp: float + + +class ReplicaQueueLengthCache: + def __init__( + self, + *, + staleness_timeout_s: float = RAY_SERVE_QUEUE_LENGTH_CACHE_TIMEOUT_S, + get_curr_time_s: Optional[Callable[[], float]] = None, + ): + self._cache: Dict[ReplicaID, ReplicaQueueLengthCacheEntry] = {} + self._staleness_timeout_s = staleness_timeout_s + self._get_curr_time_s = ( + get_curr_time_s if get_curr_time_s is not None else lambda: time.time() + ) + + def _is_timed_out(self, timestamp_s: int) -> bool: + return self._get_curr_time_s() - timestamp_s > self._staleness_timeout_s + + def get(self, replica_id: ReplicaID) -> Optional[int]: + """Get the queue length for a replica. + + Returns `None` if the replica ID is not present or the entry is timed out. + """ + entry = self._cache.get(replica_id) + if entry is None or self._is_timed_out(entry.timestamp): + return None + + return entry.queue_len + + def update(self, replica_id: ReplicaID, queue_len: int): + """Set (or update) the queue length for a replica ID.""" + self._cache[replica_id] = ReplicaQueueLengthCacheEntry( + queue_len, self._get_curr_time_s() + ) + + def invalidate_key(self, replica_id: ReplicaID): + self._cache.pop(replica_id, None) + + def remove_inactive_replicas(self, *, active_replica_ids: Set[ReplicaID]): + """Removes entries for all replica IDs not in the provided active set.""" + # NOTE: the size of the cache dictionary changes during this loop. + for replica_id in list(self._cache.keys()): + if replica_id not in active_replica_ids: + self._cache.pop(replica_id) diff --git a/lib/python3.12/site-packages/ray/serve/_private/request_router/pow_2_router.py b/lib/python3.12/site-packages/ray/serve/_private/request_router/pow_2_router.py new file mode 100644 index 0000000000000000000000000000000000000000..c929e3204a345455fe76509146a89e97ac7da456 --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/_private/request_router/pow_2_router.py @@ -0,0 +1,89 @@ +import logging +import random +from typing import ( + List, + Optional, +) + +from ray.serve._private.constants import ( + SERVE_LOGGER_NAME, +) +from ray.serve._private.request_router.common import ( + PendingRequest, +) +from ray.serve._private.request_router.replica_wrapper import ( + RunningReplica, +) +from ray.serve._private.request_router.request_router import ( + FIFOMixin, + LocalityMixin, + MultiplexMixin, + RequestRouter, +) + +logger = logging.getLogger(SERVE_LOGGER_NAME) + + +class PowerOfTwoChoicesRequestRouter( + FIFOMixin, LocalityMixin, MultiplexMixin, RequestRouter +): + """Chooses a replica for each request using the "power of two choices" procedure. + + Requests are routed in FIFO order. + + When a request comes in, two candidate replicas are chosen randomly. Each replica + is sent a control message to fetch its queue length. + + The replica responds with two items: (queue_len, accepted). Only replicas that + accept the request are considered; between those, the one with the lower queue + length is chosen. + + In the case when neither replica accepts the request (e.g., their queues are full), + the procedure is repeated with backoff. This backoff repeats indefinitely until a + replica is chosen, so the caller should use timeouts and cancellation to avoid + hangs. + + Each request being routed may spawn an independent task that runs the routing + procedure concurrently. This task will not necessarily satisfy the request that + started it (in order to maintain the FIFO order). The total number of tasks is + capped at (2 * num_replicas). + """ + + async def choose_replicas( + self, + candidate_replicas: List[RunningReplica], + pending_request: Optional[PendingRequest] = None, + ) -> List[List[RunningReplica]]: + """One iteration of the power of two choices procedure that chooses + (at most) two random available replicas. + + For multiplexing, this will first attempt to choose replicas that have the + requested model ID for a configured timeout. If no replicas with the matching + model ID are available after that timeout, it will fall back to the regular + procedure. + """ + if ( + pending_request is not None + and pending_request.metadata.multiplexed_model_id + ): + # Get candidates for multiplexed model ID. + candidate_replica_ids = self.apply_multiplex_routing( + pending_request=pending_request, + ) + else: + # Get candidates for locality preference. + candidate_replica_ids = self.apply_locality_routing( + pending_request=pending_request, + ) + + if not candidate_replica_ids: + return [] + + chosen_ids = random.sample( + list(candidate_replica_ids), + k=min(2, len(candidate_replica_ids)), + ) + replica_id_to_replica_map = { + replica.replica_id: replica for replica in candidate_replicas + } + return [[replica_id_to_replica_map[chosen_id] for chosen_id in chosen_ids]] diff --git a/lib/python3.12/site-packages/ray/serve/_private/request_router/replica_wrapper.py b/lib/python3.12/site-packages/ray/serve/_private/request_router/replica_wrapper.py new file mode 100644 index 0000000000000000000000000000000000000000..ec152d829b40847d6e285979506d6ff3937c7da7 --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/_private/request_router/replica_wrapper.py @@ -0,0 +1,185 @@ +import asyncio +import pickle +from abc import ABC, abstractmethod +from typing import Any, Dict, Optional, Set + +import ray +from ray.actor import ActorHandle +from ray.serve._private.common import ( + ReplicaID, + RunningReplicaInfo, +) +from ray.serve._private.replica_result import ActorReplicaResult, ReplicaResult +from ray.serve._private.request_router.common import PendingRequest +from ray.serve._private.utils import JavaActorHandleProxy +from ray.serve.generated.serve_pb2 import RequestMetadata as RequestMetadataProto +from ray.util.annotations import PublicAPI + + +class ReplicaWrapper(ABC): + """This is used to abstract away details of the transport layer + when communicating with the replica. + """ + + @abstractmethod + def send_request_java(self, pr: PendingRequest) -> ReplicaResult: + """Send request to Java replica.""" + pass + + @abstractmethod + def send_request_python( + self, pr: PendingRequest, *, with_rejection: bool + ) -> ReplicaResult: + """Send request to Python replica. + + If sending request with rejection, the replica will yield a + system message (ReplicaQueueLengthInfo) before executing the + actual request. This can cause it to reject the request. The + result will *always* be a generator, so for non-streaming + requests it's up to the caller to resolve it to its first (and + only) ObjectRef. + """ + pass + + +class ActorReplicaWrapper(ReplicaWrapper): + def __init__(self, actor_handle): + self._actor_handle = actor_handle + + def send_request_java(self, pr: PendingRequest) -> ActorReplicaResult: + """Send the request to a Java replica. + Does not currently support streaming. + """ + if pr.metadata.is_streaming: + raise RuntimeError("Streaming not supported for Java.") + + if len(pr.args) != 1: + raise ValueError("Java handle calls only support a single argument.") + + return ActorReplicaResult( + self._actor_handle.handle_request.remote( + RequestMetadataProto( + request_id=pr.metadata.request_id, + # Default call method in java is "call," not "__call__" like Python. + call_method="call" + if pr.metadata.call_method == "__call__" + else pr.metadata.call_method, + ).SerializeToString(), + pr.args, + ), + pr.metadata, + ) + + def send_request_python( + self, pr: PendingRequest, *, with_rejection: bool + ) -> ActorReplicaResult: + """Send the request to a Python replica.""" + if with_rejection: + # Call a separate handler that may reject the request. + # This handler is *always* a streaming call and the first message will + # be a system message that accepts or rejects. + method = self._actor_handle.handle_request_with_rejection.options( + num_returns="streaming" + ) + elif pr.metadata.is_streaming: + method = self._actor_handle.handle_request_streaming.options( + num_returns="streaming" + ) + else: + method = self._actor_handle.handle_request + + obj_ref_gen = method.remote(pickle.dumps(pr.metadata), *pr.args, **pr.kwargs) + return ActorReplicaResult( + obj_ref_gen, pr.metadata, with_rejection=with_rejection + ) + + +@PublicAPI(stability="alpha") +class RunningReplica: + """Contains info on a running replica. + Also defines the interface for a request router to talk to a replica. + """ + + def __init__(self, replica_info: RunningReplicaInfo): + self._replica_info = replica_info + self._multiplexed_model_ids = set(replica_info.multiplexed_model_ids) + + # Fetch and cache the actor handle once per RunningReplica instance. + # This avoids the borrower-of-borrower pattern while minimizing GCS lookups. + actor_handle = replica_info.get_actor_handle() + if replica_info.is_cross_language: + self._actor_handle = JavaActorHandleProxy(actor_handle) + else: + self._actor_handle = actor_handle + + @property + def replica_id(self) -> ReplicaID: + """ID of this replica.""" + return self._replica_info.replica_id + + @property + def actor_id(self) -> ray.ActorID: + """Actor ID of this replica.""" + return self._actor_handle._actor_id + + @property + def node_id(self) -> str: + """Node ID of the node this replica is running on.""" + return self._replica_info.node_id + + @property + def availability_zone(self) -> Optional[str]: + """Availability zone of the node this replica is running on.""" + return self._replica_info.availability_zone + + @property + def multiplexed_model_ids(self) -> Set[str]: + """Set of model IDs on this replica.""" + return self._multiplexed_model_ids + + @property + def routing_stats(self) -> Dict[str, Any]: + """Dictionary of routing stats.""" + return self._replica_info.routing_stats + + @property + def max_ongoing_requests(self) -> int: + """Max concurrent requests that can be sent to this replica.""" + return self._replica_info.max_ongoing_requests + + @property + def is_cross_language(self) -> bool: + """Whether this replica is cross-language (Java).""" + return self._replica_info.is_cross_language + + def _get_replica_wrapper(self, pr: PendingRequest) -> ReplicaWrapper: + return ActorReplicaWrapper(self._actor_handle) + + def push_proxy_handle(self, handle: ActorHandle): + """When on proxy, push proxy's self handle to replica""" + self._actor_handle.push_proxy_handle.remote(handle) + + async def get_queue_len(self, *, deadline_s: float) -> int: + """Returns current queue len for the replica. + `deadline_s` is passed to verify backoff for testing. + """ + # NOTE(edoakes): the `get_num_ongoing_requests` method name is shared by + # the Python and Java replica implementations. If you change it, you need to + # change both (or introduce a branch here). + obj_ref = self._actor_handle.get_num_ongoing_requests.remote() + try: + return await obj_ref + except asyncio.CancelledError: + ray.cancel(obj_ref) + raise + + def try_send_request( + self, pr: PendingRequest, with_rejection: bool + ) -> ReplicaResult: + """Try to send the request to this replica. It may be rejected.""" + wrapper = self._get_replica_wrapper(pr) + if self._replica_info.is_cross_language: + assert not with_rejection, "Request rejection not supported for Java." + return wrapper.send_request_java(pr) + + return wrapper.send_request_python(pr, with_rejection=with_rejection) diff --git a/lib/python3.12/site-packages/ray/serve/_private/request_router/request_router.py b/lib/python3.12/site-packages/ray/serve/_private/request_router/request_router.py new file mode 100644 index 0000000000000000000000000000000000000000..11ee680d24045138b8ab1f6cd098b2dac8530bb8 --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/_private/request_router/request_router.py @@ -0,0 +1,1175 @@ +import asyncio +import enum +import logging +import math +import random +import time +from abc import ABC, abstractmethod +from collections import defaultdict, deque +from typing import ( + AsyncGenerator, + Callable, + DefaultDict, + Deque, + Dict, + List, + Optional, + Set, + Tuple, +) + +from ray.actor import ActorHandle +from ray.exceptions import ActorDiedError, ActorUnavailableError +from ray.serve._private.common import ( + DeploymentHandleSource, + DeploymentID, + ReplicaID, + ReplicaQueueLengthInfo, + RequestMetadata, + RunningReplicaInfo, +) +from ray.serve._private.constants import ( + RAY_SERVE_MAX_QUEUE_LENGTH_RESPONSE_DEADLINE_S, + RAY_SERVE_MULTIPLEXED_MODEL_ID_MATCHING_TIMEOUT_S, + RAY_SERVE_QUEUE_LENGTH_RESPONSE_DEADLINE_S, + RAY_SERVE_ROUTER_RETRY_BACKOFF_MULTIPLIER, + RAY_SERVE_ROUTER_RETRY_INITIAL_BACKOFF_S, + RAY_SERVE_ROUTER_RETRY_MAX_BACKOFF_S, + SERVE_LOGGER_NAME, +) +from ray.serve._private.replica_result import ReplicaResult +from ray.serve._private.request_router.common import ( + PendingRequest, + ReplicaQueueLengthCache, +) +from ray.serve._private.request_router.replica_wrapper import RunningReplica +from ray.util import metrics +from ray.util.annotations import PublicAPI + +logger = logging.getLogger(SERVE_LOGGER_NAME) + + +class LocalityScope(str, enum.Enum): + NODE = "NODE" + AVAILABILITY_ZONE = "AVAILABILITY_ZONE" + + +@PublicAPI(stability="alpha") +class LocalityMixin: + """Mixin for locality routing. + + This mixin is used to route requests to replicas that are colocated + with the handle. It adds necessary attributes and methods to keep track of + locality scopes and offer the helpers to apply locality routing and + rank replicas based on locality. + """ + + def __init__( + self, + self_node_id: Optional[str] = None, + prefer_local_node_routing: bool = False, + prefer_local_az_routing: bool = False, + self_availability_zone: Optional[str] = None, + *args, + **kwargs, + ): + super().__init__(*args, **kwargs) + self._self_node_id = self_node_id + self._prefer_local_node_routing = prefer_local_node_routing + self._prefer_local_az_routing = prefer_local_az_routing + self._self_availability_zone = self_availability_zone + + # Colocated replicas (e.g. wrt node, AZ) + self._colocated_replica_ids: DefaultDict[ + LocalityScope, Set[ReplicaID] + ] = defaultdict(set) + self._replica_id_set: Set[ReplicaID] = set() + + def _discard_colocated_replica_ids_on_replica_actor_died( + self, replica_id: ReplicaID + ): + """Remove the replica ID from the colocated replica IDs. + This is called when a replica actor dies. + """ + for id_set in self._colocated_replica_ids.values(): + id_set.discard(replica_id) + + def _update_colocated_replica_ids_with_replicas( + self, replicas: List[RunningReplica] + ): + """Update the colocated replica IDs based on the replicas. + This is called when the replicas are updated. + """ + new_colocated_replica_ids = defaultdict(set) + + for r in replicas: + if self._self_node_id is not None and r.node_id == self._self_node_id: + new_colocated_replica_ids[LocalityScope.NODE].add(r.replica_id) + if ( + self._self_availability_zone is not None + and r.availability_zone == self._self_availability_zone + ): + new_colocated_replica_ids[LocalityScope.AVAILABILITY_ZONE].add( + r.replica_id + ) + + self._colocated_replica_ids = new_colocated_replica_ids + + def apply_locality_routing( + self, + pending_request: Optional[PendingRequest] = None, + ) -> Set[ReplicaID]: + """Apply locality routing to the pending request. + + When the reqeust is None, return all replicas. Each call will try to + route the request to replicas in the priority of first on the + same node, then in the same availability zone, and finally all + replicas. + + Args: + pending_request: The pending request to be routed. + Returns: + A set of replica IDs that are candidates based on + the locality policy. + """ + + if not pending_request: + return self._replica_id_set + + if ( + self._prefer_local_node_routing + and not pending_request.routing_context.tried_same_node + and len(self._colocated_replica_ids[LocalityScope.NODE]) > 0 + ): + # Attempt to route requests to replicas on the + # same node at most once + candidate_replica_ids = self._colocated_replica_ids[LocalityScope.NODE] + pending_request.routing_context.tried_same_node = True + pending_request.routing_context.should_backoff = False + elif ( + self._prefer_local_az_routing + and not pending_request.routing_context.tried_same_az + and len(self._colocated_replica_ids[LocalityScope.AVAILABILITY_ZONE]) > 0 + ): + # Attempt to route requests to replicas in the same + # AZ at most once + candidate_replica_ids = self._colocated_replica_ids[ + LocalityScope.AVAILABILITY_ZONE + ] + pending_request.routing_context.tried_same_az = True + pending_request.routing_context.should_backoff = False + else: + # On subsequent iterations or when there are no replicas on the same + # node or AZ, consider all available replicas. + candidate_replica_ids = self._replica_id_set + pending_request.routing_context.should_backoff = True + return candidate_replica_ids + + def rank_replicas_via_locality( + self, + replicas: List[RunningReplica], + ) -> List[List[RunningReplica]]: + """Rank the replicas based on the locality preference. + Rank 0 is the list of replicas that are on the same node. + Rank 1 is the list of replicas that are on the same availability zone. + Rank 2 is the list of all other replicas. + """ + ranked_replicas = [[] for _ in range(3)] + for replica in replicas: + if replica.replica_id in self._colocated_replica_ids[LocalityScope.NODE]: + ranked_replicas[0].append(replica) + elif ( + replica.replica_id + in self._colocated_replica_ids[LocalityScope.AVAILABILITY_ZONE] + ): + ranked_replicas[1].append(replica) + else: + ranked_replicas[2].append(replica) + return ranked_replicas + + +@PublicAPI(stability="alpha") +class MultiplexMixin: + """Mixin for multiplex routing. + + This mixin is used to route requests to replicas that are multiplexed. + It adds necessary attributes and methods to keep track of multiplexed + model IDs and offer the helpers to apply multiplex routing and rank + replicas based on multiplexed model IDs. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._multiplexed_model_id_to_replica_ids: DefaultDict[ + str, Set[ReplicaID] + ] = defaultdict(set) + + # When there is no match for a multiplexed model id, we will try to fall back + # to all replicas immediately. This set is used to make sure we only fall back + # once for concurrent requests for the same model id. + # Whenever there is a match, we will remove the model id from this set. + self._multiplexed_model_id_fallback_match: Set[str] = set() + self._replica_id_set: Set[ReplicaID] = set() + self._replicas: Dict[ReplicaID, RunningReplica] = {} + + def _get_pending_request_matching_multiplexed_model_id( + self, + request_metadata: Optional[RequestMetadata] = None, + ) -> Optional[PendingRequest]: + """Matching pending request based on the request metadata.""" + if request_metadata is None or not request_metadata.multiplexed_model_id: + return None + + for pr in self._pending_requests_to_fulfill: + if ( + not pr.future.done() + and pr.metadata.multiplexed_model_id + == request_metadata.multiplexed_model_id + ): + return pr + + def _update_multiplexed_model_ids_with_replicas( + self, replicas: List[RunningReplica] + ): + """Update the multiplexed model IDs based on the replicas. + + This should be called when the replicas are updated. + """ + new_multiplexed_model_id_to_replica_ids = defaultdict(set) + + for r in replicas: + for model_id in r.multiplexed_model_ids: + new_multiplexed_model_id_to_replica_ids[model_id].add(r.replica_id) + + self._multiplexed_model_id_to_replica_ids = ( + new_multiplexed_model_id_to_replica_ids + ) + + def _get_replica_ids_with_fewest_multiplexed_models(self) -> Set[str]: + """Get the set of replicas that have the fewest multiplexed models loaded.""" + candidates = set() + sorted_replicas = sorted( + self._replicas.values(), key=lambda x: len(x.multiplexed_model_ids) + ) + least_num_multiplexed_model_ids = math.inf + for replica in sorted_replicas: + if len(replica.multiplexed_model_ids) <= least_num_multiplexed_model_ids: + candidates.add(replica.replica_id) + least_num_multiplexed_model_ids = len(replica.multiplexed_model_ids) + else: + break + + return candidates + + @property + def _multiplexed_matching_timeout(self) -> float: + return random.uniform( + RAY_SERVE_MULTIPLEXED_MODEL_ID_MATCHING_TIMEOUT_S, + RAY_SERVE_MULTIPLEXED_MODEL_ID_MATCHING_TIMEOUT_S * 2, + ) + + def apply_multiplex_routing( + self, + pending_request: Optional[PendingRequest] = None, + ) -> Set[ReplicaID]: + """Apply multiplex routing to the pending request. + + When the request is None, return all replicas. Each call will try to + route the request to the replicas that have the multiplexed model ID + to the hierarchy of first the replicas with the multiplexed model ID, + then the replicas with the fewest multiplexed models, and finally all + replicas. + + Args: + pending_request: The pending request to be routed based on + multiplexed model policy. + + Returns: + A set of replica IDs that are candidates for the existing + routing call. + """ + if not pending_request: + return self._replica_id_set + + if not pending_request.routing_context.multiplexed_start_matching_time: + pending_request.routing_context.multiplexed_start_matching_time = ( + time.time() + ) + + multiplexed_start_matching_time = ( + pending_request.routing_context.multiplexed_start_matching_time + ) + multiplexed_model_id = pending_request.metadata.multiplexed_model_id + if ( + time.time() - multiplexed_start_matching_time + < self._multiplexed_matching_timeout + ): + candidate_replica_ids = self._multiplexed_model_id_to_replica_ids.get( + multiplexed_model_id, None + ) + if ( + not candidate_replica_ids + and multiplexed_model_id + not in self._multiplexed_model_id_fallback_match + ) or pending_request.routing_context.tried_first_multiplexed_models: + # When there is no match for a multiplexed model id + # or when the replica(s) with the matching model id is busy, + # first try to fall back to replicas with the fewest models. + candidate_replica_ids = ( + self._get_replica_ids_with_fewest_multiplexed_models() + ) + self._multiplexed_model_id_fallback_match.add(multiplexed_model_id) + elif candidate_replica_ids: + self._multiplexed_model_id_fallback_match.discard(multiplexed_model_id) + pending_request.routing_context.tried_first_multiplexed_models = True + elif not pending_request.routing_context.tried_fewest_multiplexed_models: + # After the `_multiplexed_matching_timeout` is up, first try + # routing to replicas that have the fewest models loaded. + # We only try this once to avoid deterministically retrying on + # the same replicas repeatedly. + candidate_replica_ids = ( + self._get_replica_ids_with_fewest_multiplexed_models() + ) + pending_request.routing_context.tried_fewest_multiplexed_models = True + else: + # If the timeout is up, and we've already tried the candidates + # with the fewest models loaded, fall back to all replicas. + candidate_replica_ids = self._replica_id_set + + pending_request.routing_context.should_backoff = True + return candidate_replica_ids + + def rank_replicas_via_multiplex( + self, + replicas: List[RunningReplica], + multiplexed_model_id: str, + ) -> List[List[RunningReplica]]: + """Rank the replicas based on the multiplexed model ID. + Rank 0 is the list of replicas that have the multiplexed model ID. + Rank 1 is the list of replicas that have the fewest multiplexed models. + Rank 2 is the list of all other replicas. + """ + replica_ids_with_multiplexed_model = ( + self._multiplexed_model_id_to_replica_ids.get(multiplexed_model_id, set()) + ) + replica_ids_with_fewest_multiplexed_models = ( + self._get_replica_ids_with_fewest_multiplexed_models() + ) + + ranked_replicas = [[] for _ in range(3)] + for replica in replicas: + if replica.replica_id in replica_ids_with_multiplexed_model: + ranked_replicas[0].append(replica) + elif replica.replica_id in replica_ids_with_fewest_multiplexed_models: + ranked_replicas[1].append(replica) + else: + ranked_replicas[2].append(replica) + return ranked_replicas + + +@PublicAPI(stability="alpha") +class FIFOMixin: + """Mixin for FIFO routing. + + This mixin is used to route requests in FIFO order, optionally prioritizing + requests with matching metadata. RequestRouter's default behavior is + out-of-order routing and match exactly the internal request id of + the request. This mixin doesn't provide any helper methods. By including it + in your custom implementation of RequestRouter, it will override the + reqeust matching algorithm to match based on the request metadata + multiplexed model id, if available, and then fall back to the first pending + request in the queue. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def _get_pending_request_matching_metadata( + self, + request_metadata: Optional[RequestMetadata] = None, + ) -> Optional[PendingRequest]: + """Matching pending request based on the request metadata. + + If multiplex mixin is used, this will be using the multiplexed model + id for the matching. Else, it will return none as no matching pending request. + """ + if hasattr(self, "_get_pending_request_matching_multiplexed_model_id"): + return self._get_pending_request_matching_multiplexed_model_id( + request_metadata + ) + + return None + + def _fulfill_next_pending_request( + self, + replica: RunningReplica, + request_metadata: Optional[RequestMetadata] = None, + ): + """Assign the replica to the next pending request in FIFO order. + + If a pending request has been cancelled, it will be popped from the queue + and not assigned. + """ + # First try to match a pending request based on the request metadata. + matched_pending_request = self._get_pending_request_matching_metadata( + request_metadata + ) + if matched_pending_request is not None: + matched_pending_request.future.set_result(replica) + self._pending_requests_to_fulfill.remove(matched_pending_request) + return + + # If no pending request matches the request metadata, fulfill the next in the + # queue in FIFO order, passing over futures that have been cancelled. + while len(self._pending_requests_to_fulfill) > 0: + pr = self._pending_requests_to_fulfill.popleft() + if not pr.future.done(): + pr.future.set_result(replica) + break + + +@PublicAPI(stability="alpha") +class RequestRouter(ABC): + """Abstract interface for a request router (how the router calls it).""" + + """Backoff parameters for request router.""" + initial_backoff_s = RAY_SERVE_ROUTER_RETRY_INITIAL_BACKOFF_S + backoff_multiplier = RAY_SERVE_ROUTER_RETRY_BACKOFF_MULTIPLIER + max_backoff_s = RAY_SERVE_ROUTER_RETRY_MAX_BACKOFF_S + + # Deadline for replicas to respond with their queue length. If the response isn't + # received within this deadline, the replica will not be considered. + # If this deadline is repeatedly missed, it will be exponentially increased up to + # the maximum configured here. + queue_len_response_deadline_s = RAY_SERVE_QUEUE_LENGTH_RESPONSE_DEADLINE_S + """Deadline for receiving queue length info from replicas.""" + + max_queue_len_response_deadline_s = RAY_SERVE_MAX_QUEUE_LENGTH_RESPONSE_DEADLINE_S + """Maximum deadline for receiving queue length info from replicas.""" + + max_num_routing_tasks_cap = 50 + """ + Hard limit on the maximum number of routing tasks to run. Having too many of + these tasks can cause stability issue due to too much load on the local process + and many too requests in flight to fetch replicas' queue lengths. + """ + + def __init__( + self, + deployment_id: DeploymentID, + handle_source: DeploymentHandleSource, + self_actor_id: Optional[str] = None, + self_actor_handle: Optional[ActorHandle] = None, + use_replica_queue_len_cache: bool = False, + get_curr_time_s: Optional[Callable[[], float]] = None, + create_replica_wrapper_func: Optional[ + Callable[[RunningReplicaInfo], RunningReplica] + ] = None, + *args, + **kwargs, + ): + self._deployment_id = deployment_id + self._handle_source = handle_source + self._self_actor_handle = self_actor_handle + self._use_replica_queue_len_cache = use_replica_queue_len_cache + self._create_replica_wrapper_func = create_replica_wrapper_func + + # Current replicas available to be routed. + # Updated via `update_replicas`. + self._replica_id_set: Set[ReplicaID] = set() + self._replicas: Dict[ReplicaID, RunningReplica] = {} + self._replica_queue_len_cache = ReplicaQueueLengthCache( + get_curr_time_s=get_curr_time_s, + ) + + # NOTE(edoakes): Python 3.10 removed the `loop` parameter to `asyncio.Event`. + # Now, the `asyncio.Event` will call `get_running_loop` in its constructor to + # determine the loop to attach to. This class can be constructed for the handle + # from a different loop than it uses for routing, so we need to construct it + # lazily to avoid an error due to the event being attached to the wrong loop. + self._lazily_constructed_replicas_updated_event: Optional[asyncio.Event] = None + self._lazily_fetched_loop: Optional[asyncio.AbstractEventLoop] = None + + # Tasks running the routing loop. The size of this set may vary over time + # as new tasks will be routed when a request comes in or new replicas are + # added, but it will not exceed self.max_num_routing_tasks. + self._routing_tasks: Set[asyncio.Task] = set() + + # We keep two separate queues of pending requests: + # - self._pending_requests_to_fulfill is a queue that will be used to fulfill + # requests (potentially out of order) by routing tasks once they've acquired a replica. + # - self.routing is a queue that is used for tasks to + # best-effort grab the metadata of requests waiting to be fulfilled. This is + # currently used for routing tasks to know which multiplexed model IDs they + # should be trying to get replicas for. + self._pending_requests_to_fulfill: Deque[PendingRequest] = deque() + self._pending_requests_to_route: Deque[PendingRequest] = deque() + + # Prepare request router metrics. + self.num_routing_tasks_gauge = metrics.Gauge( + "serve_num_scheduling_tasks", + description="The number of request routing tasks in the router.", + tag_keys=("app", "deployment", "actor_id"), + ).set_default_tags( + { + "app": self._deployment_id.app_name, + "deployment": self._deployment_id.name, + "actor_id": self_actor_id if self_actor_id else "", + } + ) + self.num_routing_tasks_gauge.set(0) + + self.num_routing_tasks_in_backoff = 0 + self.num_routing_tasks_in_backoff_gauge = metrics.Gauge( + "serve_num_scheduling_tasks_in_backoff", + description=( + "The number of request routing tasks in the router " + "that are undergoing backoff." + ), + tag_keys=("app", "deployment", "actor_id"), + ).set_default_tags( + { + "app": self._deployment_id.app_name, + "deployment": self._deployment_id.name, + "actor_id": self_actor_id if self_actor_id else "", + } + ) + self.num_routing_tasks_in_backoff_gauge.set(self.num_routing_tasks_in_backoff) + + def initialize_state(self, **kwargs): + """ + Initialize the state of the request router. Called by the Ray Serve framework with the + contents of `RequestRouter.request_router_kwargs`. + """ + pass + + @property + def _event_loop(self) -> asyncio.AbstractEventLoop: + if self._lazily_fetched_loop is None: + self._lazily_fetched_loop = asyncio.get_running_loop() + + return self._lazily_fetched_loop + + @property + def _replicas_updated_event(self) -> asyncio.Event: + """Lazily construct `asyncio.Event`. + + See comment for self._lazily_constructed_replicas_updated_event. + """ + if self._lazily_constructed_replicas_updated_event is None: + self._lazily_constructed_replicas_updated_event = asyncio.Event() + + return self._lazily_constructed_replicas_updated_event + + @property + def num_pending_requests(self) -> int: + """Current number of requests pending assignment.""" + return len(self._pending_requests_to_fulfill) + + @property + def curr_num_routing_tasks(self) -> int: + """Current number of routing tasks running.""" + return len(self._routing_tasks) + + @property + def max_num_routing_tasks(self) -> int: + """Max number of routing tasks to run at any time.""" + return min(self.max_num_routing_tasks_cap, 2 * len(self._replicas)) + + @property + def target_num_routing_tasks(self) -> int: + """Target number of routing tasks to be running based on pending requests. + + This will never exceed `self.max_num_routing_tasks`. + """ + return min(self.num_pending_requests, self.max_num_routing_tasks) + + @property + def curr_replicas(self) -> Dict[ReplicaID, RunningReplica]: + """Current replicas available to be routed.""" + return self._replicas + + @property + def app_name(self) -> str: + """Name of the app this router is serving.""" + return self._deployment_id.app_name + + @property + def replica_queue_len_cache(self) -> ReplicaQueueLengthCache: + """Get the replica queue length cache.""" + return self._replica_queue_len_cache + + def create_replica_wrapper( + self, replica_info: RunningReplicaInfo + ) -> RunningReplica: + return self._create_replica_wrapper_func(replica_info) + + def on_replica_actor_died(self, replica_id: ReplicaID): + """Drop replica from replica set so it's not considered for future requests.""" + self._replicas.pop(replica_id, None) + self._replica_id_set.discard(replica_id) + if hasattr(self, "_discard_colocated_replica_ids_on_replica_actor_died"): + self._discard_colocated_replica_ids_on_replica_actor_died(replica_id) + + def on_replica_actor_unavailable(self, replica_id: ReplicaID): + """Invalidate cache entry so active probing is required for the next request.""" + self._replica_queue_len_cache.invalidate_key(replica_id) + + def on_new_queue_len_info( + self, replica_id: ReplicaID, queue_len_info: ReplicaQueueLengthInfo + ): + """Update queue length cache with new info received from replica.""" + if self._use_replica_queue_len_cache: + self._replica_queue_len_cache.update( + replica_id, queue_len_info.num_ongoing_requests + ) + + def on_send_request(self, replica_id: ReplicaID): + """Increment queue length cache when a request is sent to a replica.""" + if self._use_replica_queue_len_cache: + num_ongoing_requests = self._replica_queue_len_cache.get(replica_id) or 0 + self._replica_queue_len_cache.update(replica_id, num_ongoing_requests + 1) + + def update_replicas(self, replicas: List[RunningReplica]): + """Update the set of available replicas to be considered for routing. + + When the set of replicas changes, we may spawn additional routing tasks + if there are pending requests. + """ + new_replicas = {} + new_replica_id_set = set() + if hasattr(self, "_update_colocated_replica_ids_with_replicas"): + self._update_colocated_replica_ids_with_replicas(replicas) + if hasattr(self, "_update_multiplexed_model_ids_with_replicas"): + self._update_multiplexed_model_ids_with_replicas(replicas) + + for r in replicas: + # If on the proxy, replica needs to call back into the proxy with + # `receive_asgi_messages` which can be blocked when GCS is down. + # To prevent that from happening, push proxy handle eagerly + if ( + self._handle_source == DeploymentHandleSource.PROXY + and r.replica_id not in self._replicas + ): + r.push_proxy_handle(self._self_actor_handle) + + new_replicas[r.replica_id] = r + new_replica_id_set.add(r.replica_id) + + if self._replica_id_set != new_replica_id_set: + replica_id_set_strs = {r.unique_id for r in new_replica_id_set} + logger.info( + f"Got updated replicas for {self._deployment_id}: " + f"{replica_id_set_strs}.", + extra={"log_to_stderr": False}, + ) + + # Get list of new replicas + new_ids = new_replica_id_set - self._replica_id_set + replicas_to_ping = [new_replicas.get(id) for id in new_ids] + + self._replicas = new_replicas + self._replica_id_set = new_replica_id_set + self._replica_queue_len_cache.remove_inactive_replicas( + active_replica_ids=new_replica_id_set + ) + # Populate cache for new replicas + self._event_loop.create_task(self._probe_queue_lens(replicas_to_ping, 0)) + self._replicas_updated_event.set() + self._maybe_start_routing_tasks() + + async def _probe_queue_lens( + self, + replicas: List[RunningReplica], + backoff_index: int, + ) -> List[Tuple[RunningReplica, Optional[int]]]: + """Actively probe the queue length from each of the replicas. + + Sends an RPC to each replica to fetch its queue length, with a response deadline + that increases exponentially in backoff. + + Returns a list of queue lengths in the same order as the replicas passed in. + Replicas whose RPCs fail or don't respond within the deadline will have a queue + length of `None`. Replicas that return a `RayActorError` will be removed from + future consideration for requests. + + This method also updates the local cache of replica queue lengths according to + the responses. + """ + result: List[Tuple[RunningReplica, Optional[int]]] = [] + if len(replicas) == 0: + return result + + # Ensure the max deadline is always >= the initial deadline. + max_queue_len_response_deadline_s = max( + self.queue_len_response_deadline_s, + self.max_queue_len_response_deadline_s, + ) + + try: + queue_len_response_deadline_s = min( + self.queue_len_response_deadline_s * (2**backoff_index), + max_queue_len_response_deadline_s, + ) + except OverflowError: + # self.queue_len_response_deadline_s * (2**backoff_index) + # can overflow if backoff_index gets sufficiently large (e.g. + # 1024 when queue_len_response_deadline_s is 0.1). + queue_len_response_deadline_s = max_queue_len_response_deadline_s + + get_queue_len_tasks = [] + for r in replicas: + t = self._event_loop.create_task( + r.get_queue_len(deadline_s=queue_len_response_deadline_s) + ) + t.replica = r + get_queue_len_tasks.append(t) + + done, pending = await asyncio.wait( + get_queue_len_tasks, + timeout=queue_len_response_deadline_s, + return_when=asyncio.ALL_COMPLETED, + ) + for t in pending: + replica = t.replica + result.append((replica, None)) + t.cancel() + logger.warning( + f"Failed to get queue length from {replica.replica_id} " + f"within {queue_len_response_deadline_s}s. If this happens repeatedly " + "it's likely caused by high network latency in the cluster. You can " + "configure the deadline using the " + "`RAY_SERVE_QUEUE_LENGTH_RESPONSE_DEADLINE_S` environment variable." + ) + + for t in done: + replica = t.replica + if t.exception() is not None: + result.append((replica, None)) + msg = ( + "Failed to fetch queue length for " + f"{replica.replica_id}: '{t.exception()}'" + ) + # If we get an ActorDiedError, the replica actor has died. This + # is not recoverable (the controller will start a new replica in its + # place), so we should no longer consider it for requests. + # We do not catch RayActorError here because that error can be + # raised even when a replica is temporarily unavailable. + # See https://github.com/ray-project/ray/issues/44185 for details. + if isinstance(t.exception(), ActorDiedError): + self.on_replica_actor_died(replica.replica_id) + msg += " This replica will no longer be considered for requests." + # Replica is temporarily unavailable because of network issues, or + # replica has died but GCS is down so ActorUnavailableError will + # be raised until the GCS recovers. For the time being, invalidate + # the cache entry so that we don't try to send requests to this + # replica without actively probing. + elif isinstance(t.exception(), ActorUnavailableError): + self.on_replica_actor_unavailable(replica.replica_id) + msg = ( + "Failed to fetch queue length for " + f"{replica.replica_id}. Replica is temporarily " + "unavailable." + ) + + logger.warning(msg) + else: + queue_len = t.result() + result.append((replica, queue_len)) + self._replica_queue_len_cache.update(replica.replica_id, queue_len) + + assert len(result) == len(replicas) + return result + + async def _select_from_candidate_replicas( + self, + candidates: List[RunningReplica], + backoff_index: int, + ) -> Optional[RunningReplica]: + """Chooses the best replica from the list of candidates. + + If none of the replicas can be routed, returns `None`. + + The queue length for each replica is first looked up in the local cache. If not + present in the cache, the replica will be actively probed and the cache updated. + + Among replicas that respond within the deadline and don't have full queues, the + one with the lowest queue length is chosen. + """ + lowest_queue_len = math.inf + chosen_replica_id: Optional[str] = None + not_in_cache: List[RunningReplica] = [] + if self._use_replica_queue_len_cache: + # Populate available queue lens from the cache. + for r in candidates: + queue_len = self._replica_queue_len_cache.get(r.replica_id) + # Include replicas whose queues are full as not in the cache so we will + # actively probe them. Otherwise we may end up in "deadlock" until their + # cache entries expire. + if queue_len is None or queue_len >= r.max_ongoing_requests: + not_in_cache.append(r) + elif queue_len < lowest_queue_len: + lowest_queue_len = queue_len + chosen_replica_id = r.replica_id + else: + not_in_cache = candidates + + # If there is a valid replica to route based on the information in the + # cache, route it. Else fall back to actively probing. + if chosen_replica_id is None: + for r, queue_len in await self._probe_queue_lens( + not_in_cache, + backoff_index, + ): + if queue_len is None: + # None is returned if we failed to get the queue len. + continue + + if queue_len < r.max_ongoing_requests and queue_len < lowest_queue_len: + lowest_queue_len = queue_len + chosen_replica_id = r.replica_id + elif len(not_in_cache) > 0: + # If there are replicas without a valid cache entry, probe them in the + # background to populate the cache. + self._event_loop.create_task( + self._probe_queue_lens(not_in_cache, backoff_index) + ) + + # `self._replicas` may have been updated since the candidates were chosen. + # In that case, return `None` so a new one is selected. + return self._replicas.get(chosen_replica_id, None) + + def _get_pending_request_matching_internal_request_id( + self, + request_metadata: Optional[RequestMetadata] = None, + ) -> Optional[PendingRequest]: + """Get the pending request that matches on the internal request id. + + If no request metadata is provided or no request is found that matches + the internal request ID, return None. + """ + if request_metadata is None: + return None + + for pr in self._pending_requests_to_fulfill: + if ( + not pr.future.done() + and pr.metadata.internal_request_id + == request_metadata.internal_request_id + ): + return pr + + return None + + def _fulfill_next_pending_request( + self, + replica: RunningReplica, + request_metadata: Optional[RequestMetadata] = None, + ): + """Assign the replica to the next pending request, potentially not in + order of when the request arrived. + + If a pending request has been cancelled, it will be popped from the queue + and not assigned. + """ + # Find the pending request that matches exactly. + matched_pending_request = ( + self._get_pending_request_matching_internal_request_id(request_metadata) + ) + if matched_pending_request is not None: + matched_pending_request.future.set_result(replica) + self._pending_requests_to_fulfill.remove(matched_pending_request) + return + + def _get_next_pending_request_to_route( + self, + ) -> Optional[PendingRequest]: + while len(self._pending_requests_to_route) > 0: + pr = self._pending_requests_to_route.popleft() + if not pr.future.done(): + return pr + + return None + + async def _choose_replicas_with_backoff( + self, + pending_request: Optional[PendingRequest] = None, + ) -> AsyncGenerator[List[RunningReplica], None]: + """Generator that repeatedly chooses available replicas. + In the first iteration, only replicas colocated on the same node as this router + will be considered. If those are occupied, the full set of replicas will be + considered on subsequent iterations. + After each iteration, there will be an increasing backoff sleep time (dictated + by `initial_backoff_s` and `backoff_multiplier`). The caller should exit the + generator to reset the backoff sleep time. + """ + entered_backoff = False + try: + attempt = 0 + + while True: + # If no replicas are available, wait until `update_replicas` is called. + while len(self._replicas) == 0: + logger.info( + "No replicas are currently available for " + f"{self._deployment_id}.", + extra={"log_to_stderr": False}, + ) + self._replicas_updated_event.clear() + await self._replicas_updated_event.wait() + logger.info( + f"New replicas are available for {self._deployment_id}, " + "attempting to route queued requests.", + extra={"log_to_stderr": False}, + ) + + replica_ranks = list(self._replicas.values()) + chosen_replicas: List[ + List[RunningReplica] + ] = await self.choose_replicas( + candidate_replicas=replica_ranks, + pending_request=pending_request, + ) + for replicas in chosen_replicas: + if replicas: + yield replicas + + # We have a slight unintended behavior when enabled locality routing + # for both node and AZ. The intention is to try same node first, + # then try same AZ if node fails, then try everything else until a + # replica is found. These sequence should only help to reduce the + # latency of the request. No backoff and sleep should be applied, until + # we have fall into the case trying on all available replicas. + if ( + pending_request + and not pending_request.routing_context.should_backoff + ): + continue + + if not entered_backoff: + entered_backoff = True + self.num_routing_tasks_in_backoff += 1 + self.num_routing_tasks_in_backoff_gauge.set( + self.num_routing_tasks_in_backoff + ) + else: + # Only backoff after the first retry. + backoff_s = min( + self.initial_backoff_s * self.backoff_multiplier**attempt, + self.max_backoff_s, + ) + await asyncio.sleep(backoff_s) + attempt += 1 + finally: + if entered_backoff: + self.num_routing_tasks_in_backoff -= 1 + self.num_routing_tasks_in_backoff_gauge.set( + self.num_routing_tasks_in_backoff + ) + + async def _fulfill_pending_requests(self): + """Repeatedly tries to fulfill a pending request with an available replica. + + This is expected to be run inside a task in self._routing_tasks. + + When a replica is found, this method will exit if the number of routing tasks + has exceeded the target number. Else it will loop again to route another + replica. + """ + try: + while len(self._routing_tasks) <= self.target_num_routing_tasks: + start_time = time.time() + backoff_index = 0 + pending_request = self._get_next_pending_request_to_route() + request_metadata = pending_request.metadata if pending_request else None + gen_choose_replicas_with_backoff = self._choose_replicas_with_backoff( + pending_request + ) + try: + async for candidates in gen_choose_replicas_with_backoff: + # Clear out pending requests at the front of the + # queue that have been cancelled, then reevaluate + # if we need to continue this routing task. + while ( + len(self._pending_requests_to_fulfill) > 0 + and self._pending_requests_to_fulfill[0].future.done() + ): + self._pending_requests_to_fulfill.popleft() + + if len(self._routing_tasks) > self.target_num_routing_tasks: + break + + replica = await self._select_from_candidate_replicas( + candidates, backoff_index + ) + if replica is not None: + self._fulfill_next_pending_request( + replica, request_metadata + ) + break + + backoff_index += 1 + if backoff_index >= 50 and backoff_index % 50 == 0: + routing_time_elapsed = time.time() - start_time + warning_log = ( + "Failed to route request after " + f"{backoff_index} attempts over " + f"{routing_time_elapsed:.2f}s. Retrying." + ) + if request_metadata is not None: + warning_log += ( + f" Request ID: {request_metadata.request_id}." + ) + if request_metadata.multiplexed_model_id: + warning_log += ( + " Multiplexed model ID: " + f"{request_metadata.multiplexed_model_id}." + ) + logger.warning(warning_log) + finally: + await gen_choose_replicas_with_backoff.aclose() + + except Exception: + logger.exception("Unexpected error in _fulfill_pending_requests.") + finally: + self._routing_tasks.remove(asyncio.current_task(loop=self._event_loop)) + self.num_routing_tasks_gauge.set(self.curr_num_routing_tasks) + + def _maybe_start_routing_tasks(self): + """Start routing tasks to fulfill pending requests if necessary. + + Starts tasks so that there is at least one task per pending request + (respecting the max number of routing tasks). + + In the common case, this will start a single task when a new request comes + in for routing. However, in cases where the number of available replicas + is updated or a task exits unexpectedly, we may need to start multiple. + """ + tasks_to_start = self.target_num_routing_tasks - self.curr_num_routing_tasks + for _ in range(tasks_to_start): + self._routing_tasks.add( + self._event_loop.create_task(self._fulfill_pending_requests()) + ) + if tasks_to_start > 0: + self.num_routing_tasks_gauge.set(self.curr_num_routing_tasks) + + async def _choose_replica_for_request( + self, pending_request: PendingRequest, *, is_retry: bool = False + ) -> RunningReplica: + """Chooses a replica to send the provided request to. + + Upon cancellation (by the caller), the future is cancelled and will be passed + over when a replica becomes available. + """ + try: + if not is_retry: + self._pending_requests_to_fulfill.append(pending_request) + self._pending_requests_to_route.append(pending_request) + else: + pending_request.reset_future() + index = 0 + for pr in self._pending_requests_to_fulfill: + if pending_request.created_at < pr.created_at: + break + + index += 1 + + self._pending_requests_to_fulfill.insert(index, pending_request) + + index = 0 + for pr in self._pending_requests_to_route: + if pending_request.created_at < pr.created_at: + break + + index += 1 + + self._pending_requests_to_route.insert(index, pending_request) + + self._maybe_start_routing_tasks() + replica = await pending_request.future + except asyncio.CancelledError as e: + pending_request.future.cancel() + + raise e from None + + return replica + + def _update_running_replicas(self, running_replicas: List[RunningReplicaInfo]): + """Compatibility shim for RunningReplicaInfo datatype.""" + replica_wrappers = [] + for r in running_replicas: + try: + replica_wrappers.append(self.create_replica_wrapper(r)) + except ValueError: + # NOTE(abrar): ValueError is raised when the actor handle is not found + # by ray.get_actor. + + # Actor has died (e.g., due to node failure) but controller hasn't + # detected it yet. Skip this replica; controller will send an update + # when it detects the failure. + logger.warning( + f"Failed to get handle to replica {r.replica_id} during router " + "update. The replica actor may have died. Skipping this replica." + ) + return self.update_replicas(replica_wrappers) + + def select_available_replicas( + self, candidates: Optional[List[RunningReplica]] = None + ) -> List[RunningReplica]: + """Select available replicas from the list of candidates. + + This method is used to select replicas that are available to take more + requests based on the queue length cache. If the queue length is not + available in the cache, the replica is considered available. It does + not actively probe the replicas for their queue length. + + If input candidates is `None`, all replicas are considered. + """ + if candidates is None: + candidates = list(self._replicas.values()) + + available_replicas = [] + for r in candidates: + queue_len = self._replica_queue_len_cache.get(r.replica_id) + if queue_len is None or queue_len < r.max_ongoing_requests: + available_replicas.append(r) + + return available_replicas + + @abstractmethod + async def choose_replicas( + self, + candidate_replicas: List[RunningReplica], + pending_request: Optional[PendingRequest] = None, + ) -> List[List[RunningReplica]]: + """Chooses a subset of candidate replicas from available replicas. + + This is the main function each request router should implement to + decide which replica to send the request to. This is one iteration of + replica selection. + + Args: + candidate_replicas: A list of candidate replicas to be considered in the + policy. + pending_request: The request to be routed. This is used to + determine which replicas are eligible for routing. + + Returns: + A list of lists of replicas, where each inner list represents a + rank of replicas. The first rank is the most preferred and the last + rank is the least preferred. + """ + pass + + def on_request_routed( + self, + pending_request: PendingRequest, + replica_id: ReplicaID, + result: ReplicaResult, + ): + """Called when a request is routed to a replica. + + This is used as a callback to update the state of the request router + after a response is generated. + """ + pass diff --git a/lib/python3.12/site-packages/ray/serve/_private/router.py b/lib/python3.12/site-packages/ray/serve/_private/router.py new file mode 100644 index 0000000000000000000000000000000000000000..e4cd1eff9f4ac7139931bb993ead5f638e4876e4 --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/_private/router.py @@ -0,0 +1,1163 @@ +import asyncio +import concurrent.futures +import logging +import threading +import time +import weakref +from abc import ABC, abstractmethod +from asyncio import AbstractEventLoop, ensure_future, futures +from collections import defaultdict +from collections.abc import MutableMapping +from contextlib import contextmanager +from functools import lru_cache, partial +from typing import ( + Any, + Callable, + Coroutine, + DefaultDict, + Dict, + List, + Optional, + Union, +) + +import ray +from ray.actor import ActorHandle +from ray.exceptions import ActorDiedError, ActorUnavailableError, RayError +from ray.serve._private.common import ( + RUNNING_REQUESTS_KEY, + DeploymentHandleSource, + DeploymentID, + DeploymentTargetInfo, + HandleMetricReport, + ReplicaID, + RequestMetadata, + RunningReplicaInfo, +) +from ray.serve._private.config import DeploymentConfig +from ray.serve._private.constants import ( + RAY_SERVE_COLLECT_AUTOSCALING_METRICS_ON_HANDLE, + RAY_SERVE_HANDLE_AUTOSCALING_METRIC_RECORD_INTERVAL_S, + RAY_SERVE_METRICS_EXPORT_INTERVAL_MS, + RAY_SERVE_PROXY_PREFER_LOCAL_AZ_ROUTING, + SERVE_LOGGER_NAME, +) +from ray.serve._private.long_poll import LongPollClient, LongPollNamespace +from ray.serve._private.metrics_utils import ( + QUEUED_REQUESTS_KEY, + InMemoryMetricsStore, + MetricsPusher, + TimeStampedValue, +) +from ray.serve._private.replica_result import ReplicaResult +from ray.serve._private.request_router import PendingRequest, RequestRouter +from ray.serve._private.request_router.pow_2_router import ( + PowerOfTwoChoicesRequestRouter, +) +from ray.serve._private.request_router.replica_wrapper import RunningReplica +from ray.serve._private.usage import ServeUsageTag +from ray.serve._private.utils import ( + generate_request_id, + resolve_deployment_response, +) +from ray.serve.config import AutoscalingConfig +from ray.serve.exceptions import BackPressureError, DeploymentUnavailableError +from ray.util import metrics + +logger = logging.getLogger(SERVE_LOGGER_NAME) + + +class RouterMetricsManager: + """Manages metrics for the router.""" + + PUSH_METRICS_TO_CONTROLLER_TASK_NAME = "push_metrics_to_controller" + RECORD_METRICS_TASK_NAME = "record_metrics" + + def __init__( + self, + deployment_id: DeploymentID, + handle_id: str, + self_actor_id: str, + handle_source: DeploymentHandleSource, + controller_handle: ActorHandle, + router_requests_counter: metrics.Counter, + queued_requests_gauge: metrics.Gauge, + running_requests_gauge: metrics.Gauge, + event_loop: asyncio.BaseEventLoop, + ): + self._handle_id = handle_id + self._deployment_id = deployment_id + self._self_actor_id = self_actor_id + self._handle_source = handle_source + self._controller_handle = controller_handle + + # Exported metrics + self.num_router_requests = router_requests_counter + self.num_router_requests.set_default_tags( + { + "deployment": deployment_id.name, + "application": deployment_id.app_name, + "handle": self._handle_id, + "actor_id": self._self_actor_id, + } + ) + + self.num_queued_requests = 0 + self.num_queued_requests_gauge = queued_requests_gauge + self.num_queued_requests_gauge.set_default_tags( + { + "deployment": deployment_id.name, + "application": deployment_id.app_name, + "handle": self._handle_id, + "actor_id": self._self_actor_id, + } + ) + self.num_queued_requests_gauge.set(0) + + # Track queries sent to replicas for the autoscaling algorithm. + self.num_requests_sent_to_replicas: DefaultDict[ReplicaID, int] = defaultdict( + int + ) + self.num_running_requests_gauge = running_requests_gauge + self.num_running_requests_gauge.set_default_tags( + { + "deployment": deployment_id.name, + "application": deployment_id.app_name, + "handle": self._handle_id, + "actor_id": self._self_actor_id, + } + ) + # We use Ray object ref callbacks to update state when tracking + # number of requests running on replicas. The callbacks will be + # called from a C++ thread into the router's async event loop, + # so non-atomic read and write operations need to be guarded by + # this thread-safe lock. + self._queries_lock = threading.Lock() + # Regularly aggregate and push autoscaling metrics to controller + self.metrics_pusher = MetricsPusher() + self.metrics_store = InMemoryMetricsStore() + # The config for the deployment this router sends requests to will be broadcast + # by the controller. That means it is not available until we get the first + # update. This includes an optional autoscaling config. + self._deployment_config: Optional[DeploymentConfig] = None + # Track whether the metrics manager has been shutdown + self._shutdown: bool = False + + # If the interval is set to 0, eagerly sets all metrics. + self._cached_metrics_enabled = RAY_SERVE_METRICS_EXPORT_INTERVAL_MS != 0 + self._cached_metrics_interval_s = RAY_SERVE_METRICS_EXPORT_INTERVAL_MS / 1000 + + if self._cached_metrics_enabled: + self._cached_num_router_requests = defaultdict(int) + + def create_metrics_task(): + event_loop.create_task(self._report_cached_metrics_forever()) + + # the constructor is called in the user thread, but its trying to create a task on the event loop + # which is running in the router thread. This is not thread safe, so we need to use call_soon_threadsafe + # to create the task on the event loop thread safely. + event_loop.call_soon_threadsafe(create_metrics_task) + + @contextmanager + def wrap_request_assignment(self, request_meta: RequestMetadata): + max_queued_requests = ( + self._deployment_config.max_queued_requests + if self._deployment_config is not None + else -1 + ) + if ( + max_queued_requests != -1 + and self.num_queued_requests >= max_queued_requests + ): + # Due to the async nature of request handling, we may reject more requests + # than strictly necessary. This is more likely to happen during + # high concurrency. Here's why: + # + # When multiple requests arrive simultaneously with max_queued_requests=1: + # 1. First request increments num_queued_requests to 1 + # 2. Before that request gets assigned to a replica and decrements the counter, + # we yield to the event loop + # 3. Other requests see num_queued_requests=1 and get rejected, even though + # the first request will soon free up the queue slot + # + # For example, with max_queued_requests=1 and 4 simultaneous requests: + # - Request 1 gets queued (num_queued_requests=1) + # - Requests 2,3,4 get rejected since queue appears full + # - Request 1 gets assigned and frees queue slot (num_queued_requests=0) + # - But we already rejected Request 2 which could have been queued + e = BackPressureError( + num_queued_requests=self.num_queued_requests, + max_queued_requests=max_queued_requests, + ) + logger.warning(e.message) + raise e + + self.inc_num_total_requests(request_meta.route) + yield + + @contextmanager + def wrap_queued_request(self, is_retry: bool, num_curr_replicas: int): + """Increment queued requests gauge and maybe push autoscaling metrics to controller.""" + try: + self.inc_num_queued_requests() + # Optimization: if there are currently zero replicas for a deployment, + # push handle metric to controller to allow for fast cold start time. + # Only do this on the first attempt to route the request. + if not is_retry and self.should_send_scaled_to_zero_optimized_push( + curr_num_replicas=num_curr_replicas + ): + self.push_autoscaling_metrics_to_controller() + + yield + finally: + # If the request is disconnected before assignment, this coroutine + # gets cancelled by the caller and an asyncio.CancelledError is + # raised. The finally block ensures that num_queued_requests + # is correctly decremented in this case. + self.dec_num_queued_requests() + + def _update_running_replicas(self, running_replicas: List[RunningReplicaInfo]): + """Prune list of replica ids in self.num_queries_sent_to_replicas. + + We want to avoid self.num_queries_sent_to_replicas from growing + in memory as the deployment upscales and downscales over time. + """ + + running_replica_set = {replica.replica_id for replica in running_replicas} + with self._queries_lock: + self.num_requests_sent_to_replicas = defaultdict( + int, + { + id: self.num_requests_sent_to_replicas[id] + for id, num_queries in self.num_requests_sent_to_replicas.items() + if num_queries or id in running_replica_set + }, + ) + + @property + def autoscaling_config(self) -> Optional[AutoscalingConfig]: + if self._deployment_config is None: + return None + + return self._deployment_config.autoscaling_config + + def update_deployment_config( + self, deployment_config: DeploymentConfig, curr_num_replicas: int + ): + """Update the config for the deployment this router sends requests to.""" + + if self._shutdown: + return + + self._deployment_config = deployment_config + + # Start the metrics pusher if autoscaling is enabled. + autoscaling_config = self.autoscaling_config + if autoscaling_config: + self.metrics_pusher.start() + # Optimization for autoscaling cold start time. If there are + # currently 0 replicas for the deployment, and there is at + # least one queued request on this router, then immediately + # push handle metric to the controller. + if self.should_send_scaled_to_zero_optimized_push(curr_num_replicas): + self.push_autoscaling_metrics_to_controller() + + # Record number of queued + ongoing requests at regular + # intervals into the in-memory metrics store + self.metrics_pusher.register_or_update_task( + self.RECORD_METRICS_TASK_NAME, + self._add_autoscaling_metrics_point, + min( + RAY_SERVE_HANDLE_AUTOSCALING_METRIC_RECORD_INTERVAL_S, + autoscaling_config.metrics_interval_s, + ), + ) + # Push metrics to the controller periodically. + self.metrics_pusher.register_or_update_task( + self.PUSH_METRICS_TO_CONTROLLER_TASK_NAME, + self.push_autoscaling_metrics_to_controller, + autoscaling_config.metrics_interval_s, + ) + + else: + if self.metrics_pusher: + self.metrics_pusher.stop_tasks() + + def _report_cached_metrics(self): + for route, count in self._cached_num_router_requests.items(): + self.num_router_requests.inc(count, tags={"route": route}) + self._cached_num_router_requests.clear() + + self.num_queued_requests_gauge.set(self.num_queued_requests) + + self.num_running_requests_gauge.set( + sum(self.num_requests_sent_to_replicas.values()) + ) + + async def _report_cached_metrics_forever(self): + assert self._cached_metrics_interval_s > 0 + + consecutive_errors = 0 + while True: + try: + await asyncio.sleep(self._cached_metrics_interval_s) + self._report_cached_metrics() + consecutive_errors = 0 + except Exception: + logger.exception("Unexpected error reporting metrics.") + + # Exponential backoff starting at 1s and capping at 10s. + backoff_time_s = min(10, 2**consecutive_errors) + consecutive_errors += 1 + await asyncio.sleep(backoff_time_s) + + def inc_num_total_requests(self, route: str): + if self._cached_metrics_enabled: + self._cached_num_router_requests[route] += 1 + else: + self.num_router_requests.inc(tags={"route": route}) + + def inc_num_queued_requests(self): + self.num_queued_requests += 1 + if not self._cached_metrics_enabled: + self.num_queued_requests_gauge.set(self.num_queued_requests) + + def dec_num_queued_requests(self): + self.num_queued_requests -= 1 + if not self._cached_metrics_enabled: + self.num_queued_requests_gauge.set(self.num_queued_requests) + + def inc_num_running_requests_for_replica(self, replica_id: ReplicaID): + with self._queries_lock: + self.num_requests_sent_to_replicas[replica_id] += 1 + if not self._cached_metrics_enabled: + self.num_running_requests_gauge.set( + sum(self.num_requests_sent_to_replicas.values()) + ) + + def dec_num_running_requests_for_replica(self, replica_id: ReplicaID): + with self._queries_lock: + self.num_requests_sent_to_replicas[replica_id] -= 1 + if not self._cached_metrics_enabled: + self.num_running_requests_gauge.set( + sum(self.num_requests_sent_to_replicas.values()) + ) + + def should_send_scaled_to_zero_optimized_push(self, curr_num_replicas: int) -> bool: + return ( + self.autoscaling_config is not None + and curr_num_replicas == 0 + and self.num_queued_requests > 0 + ) + + def push_autoscaling_metrics_to_controller(self): + """Pushes queued and running request metrics to the controller. + + These metrics are used by the controller for autoscaling. + """ + self._controller_handle.record_autoscaling_metrics_from_handle.remote( + self._get_metrics_report() + ) + + def _add_autoscaling_metrics_point(self): + """Adds metrics point for queued and running requests at replicas. + + Also prunes keys in the in memory metrics store with outdated datapoints. + + ┌─────────────────────────────────────────────────────────────────┐ + │ Handle-based metrics collection │ + ├─────────────────────────────────────────────────────────────────┤ + │ │ + │ Client Handle Replicas │ + │ ┌──────┐ ┌────────┐ ┌─────────┐ │ + │ │ App │───────────>│ Handle │─────────>│ Replica │ │ + │ │ │ Requests │ │ Forwards │ 1 │ │ + │ └──────┘ │ Tracks │ └─────────┘ │ + │ │ Queued │ │ + │ │ + │ ┌─────────┐ │ + │ │Running │─────────>│ Replica │ │ + │ │Requests│ Forwards │ 2 │ │ + │ └────────┘ └─────────┘ │ + │ │ │ + │ │ Push metrics │ + │ └─────────────────> Controller │ + │ │ + └─────────────────────────────────────────────────────────────────┘ + + :::{note} + The long-term plan is to deprecate handle-based metrics collection in favor of + replica-based collection. Replica-based collection will become the default in a + future release. Queued requests will be continues to be tracked at the handle. + ::: + """ + + timestamp = time.time() + self.metrics_store.add_metrics_point( + {QUEUED_REQUESTS_KEY: self.num_queued_requests}, timestamp + ) + if RAY_SERVE_COLLECT_AUTOSCALING_METRICS_ON_HANDLE: + self.metrics_store.add_metrics_point( + self.num_requests_sent_to_replicas, timestamp + ) + + # Prevent in memory metrics store memory from growing + start_timestamp = time.time() - self.autoscaling_config.look_back_period_s + self.metrics_store.prune_keys_and_compact_data(start_timestamp) + + def _get_metrics_report(self) -> HandleMetricReport: + timestamp = time.time() + running_requests = dict() + avg_running_requests = dict() + look_back_period = self.autoscaling_config.look_back_period_s + self.metrics_store.prune_keys_and_compact_data(time.time() - look_back_period) + avg_queued_requests = self.metrics_store.aggregate_avg([QUEUED_REQUESTS_KEY])[0] + if avg_queued_requests is None: + # If the queued requests timeseries is empty, we set the + # average to the current number of queued requests. + avg_queued_requests = self.num_queued_requests + # If the queued requests timeseries is empty, we set the number of data points to 1. + # This is to avoid division by zero. + num_data_points = self.metrics_store.timeseries_count(QUEUED_REQUESTS_KEY) or 1 + queued_requests = self.metrics_store.data.get( + QUEUED_REQUESTS_KEY, [TimeStampedValue(timestamp, self.num_queued_requests)] + ) + if RAY_SERVE_COLLECT_AUTOSCALING_METRICS_ON_HANDLE and self.autoscaling_config: + for replica_id, num_requests in self.num_requests_sent_to_replicas.items(): + # Calculate avg running requests. + # NOTE (abrar): The number of data points from queued requests is often higher than + # those from running requests. This is because replica metrics are only collected + # once a replica is up, whereas queued request metrics are collected continuously + # as long as the handle is alive. To approximate the true average of ongoing requests, + # we should normalize by using the same number of data points for both queued and + # running request time series. + running_requests_sum = self.metrics_store.aggregate_sum([replica_id])[0] + if running_requests_sum is None: + # If the running requests timeseries is empty, we set the sum + # to the current number of requests. + running_requests_sum = num_requests + avg_running_requests[replica_id] = ( + running_requests_sum / num_data_points + ) + # Get running requests data + running_requests[replica_id] = self.metrics_store.data.get( + replica_id, [TimeStampedValue(timestamp, num_requests)] + ) + handle_metric_report = HandleMetricReport( + deployment_id=self._deployment_id, + handle_id=self._handle_id, + actor_id=self._self_actor_id, + handle_source=self._handle_source, + aggregated_queued_requests=avg_queued_requests, + queued_requests=queued_requests, + aggregated_metrics={ + RUNNING_REQUESTS_KEY: avg_running_requests, + }, + metrics={ + RUNNING_REQUESTS_KEY: running_requests, + }, + timestamp=timestamp, + ) + + return handle_metric_report + + async def shutdown(self): + """Shutdown metrics manager gracefully.""" + + if self.metrics_pusher: + await self.metrics_pusher.graceful_shutdown() + + self._shutdown = True + + +class Router(ABC): + @abstractmethod + def running_replicas_populated(self) -> bool: + pass + + @abstractmethod + def assign_request( + self, + request_meta: RequestMetadata, + *request_args, + **request_kwargs, + ) -> concurrent.futures.Future[ReplicaResult]: + pass + + @abstractmethod + def shutdown(self) -> concurrent.futures.Future: + pass + + +async def create_event() -> asyncio.Event: + """Helper to create an asyncio event in the current event loop.""" + return asyncio.Event() + + +class AsyncioRouter: + def __init__( + self, + controller_handle: ActorHandle, + deployment_id: DeploymentID, + handle_id: str, + self_actor_id: str, + handle_source: DeploymentHandleSource, + event_loop: asyncio.BaseEventLoop, + enable_strict_max_ongoing_requests: bool, + node_id: str, + availability_zone: Optional[str], + prefer_local_node_routing: bool, + resolve_request_arg_func: Coroutine = resolve_deployment_response, + request_router_class: Optional[Callable] = None, + request_router_kwargs: Optional[Dict[str, Any]] = None, + request_router: Optional[RequestRouter] = None, + _request_router_initialized_event: Optional[asyncio.Event] = None, + ): + """Used to assign requests to downstream replicas for a deployment. + + The routing behavior is delegated to a RequestRouter; this is a thin + wrapper that adds metrics and logging. + """ + self._controller_handle = controller_handle + self.deployment_id = deployment_id + self._self_actor_id = self_actor_id + self._handle_source = handle_source + self._event_loop = event_loop + self._request_router_class = request_router_class + self._request_router_kwargs = ( + request_router_kwargs if request_router_kwargs else {} + ) + self._enable_strict_max_ongoing_requests = enable_strict_max_ongoing_requests + self._node_id = node_id + self._availability_zone = availability_zone + self._prefer_local_node_routing = prefer_local_node_routing + # By default, deployment is available unless we receive news + # otherwise through a long poll broadcast from the controller. + self._deployment_available = True + + # The request router will be lazy loaded to decouple form the initialization. + self._request_router: Optional[RequestRouter] = request_router + + if _request_router_initialized_event: + self._request_router_initialized = _request_router_initialized_event + else: + future = asyncio.run_coroutine_threadsafe(create_event(), self._event_loop) + self._request_router_initialized = future.result() + + if self._request_router: + self._request_router_initialized.set() + self._resolve_request_arg_func = resolve_request_arg_func + self._running_replicas: Optional[List[RunningReplicaInfo]] = None + + # Flipped to `True` once the router has received a non-empty + # replica set at least once. + self._running_replicas_populated: bool = False + + # Initializing `self._metrics_manager` before `self.long_poll_client` is + # necessary to avoid race condition where `self.update_deployment_config()` + # might be called before `self._metrics_manager` instance is created. + self._metrics_manager = RouterMetricsManager( + deployment_id, + handle_id, + self_actor_id, + handle_source, + controller_handle, + metrics.Counter( + "serve_num_router_requests", + description="The number of requests processed by the router.", + tag_keys=("deployment", "route", "application", "handle", "actor_id"), + ), + metrics.Gauge( + "serve_deployment_queued_queries", + description=( + "The current number of queries to this deployment waiting" + " to be assigned to a replica." + ), + tag_keys=("deployment", "application", "handle", "actor_id"), + ), + metrics.Gauge( + "serve_num_ongoing_requests_at_replicas", + description=( + "The current number of requests to this deployment that " + "have been submitted to a replica." + ), + tag_keys=("deployment", "application", "handle", "actor_id"), + ), + event_loop, + ) + + # The Router needs to stay informed about changes to the target deployment's + # running replicas and deployment config. We do this via the long poll system. + # However, for efficiency, we don't want to create a LongPollClient for every + # DeploymentHandle, so we use a shared LongPollClient that all Routers + # register themselves with. But first, the router needs to get a fast initial + # update so that it can start serving requests, which we do with a dedicated + # LongPollClient that stops running once the shared client takes over. + + self.long_poll_client = LongPollClient( + controller_handle, + { + ( + LongPollNamespace.DEPLOYMENT_TARGETS, + deployment_id, + ): self.update_deployment_targets, + ( + LongPollNamespace.DEPLOYMENT_CONFIG, + deployment_id, + ): self.update_deployment_config, + }, + call_in_event_loop=self._event_loop, + ) + + shared = SharedRouterLongPollClient.get_or_create( + controller_handle, self._event_loop + ) + shared.register(self) + + @property + def request_router(self) -> Optional[RequestRouter]: + """Get and lazy loading request router. + + If the request_router_class not provided, and the request router is not + yet initialized, then it will return None. Otherwise, if request router + is not yet initialized, it will be initialized and returned. Also, + setting `self._request_router_initialized` to signal that the request + router is initialized. + """ + if not self._request_router and self._request_router_class: + request_router = self._request_router_class( + deployment_id=self.deployment_id, + handle_source=self._handle_source, + self_node_id=self._node_id, + self_actor_id=self._self_actor_id, + self_actor_handle=ray.get_runtime_context().current_actor + if ray.get_runtime_context().get_actor_id() + else None, + # Streaming ObjectRefGenerators are not supported in Ray Client + use_replica_queue_len_cache=self._enable_strict_max_ongoing_requests, + create_replica_wrapper_func=lambda r: RunningReplica(r), + prefer_local_node_routing=self._prefer_local_node_routing, + prefer_local_az_routing=RAY_SERVE_PROXY_PREFER_LOCAL_AZ_ROUTING, + self_availability_zone=self._availability_zone, + ) + request_router.initialize_state(**(self._request_router_kwargs)) + + # Populate the running replicas if they are already available. + if self._running_replicas is not None: + request_router._update_running_replicas(self._running_replicas) + + self._request_router = request_router + self._request_router_initialized.set() + + # Log usage telemetry to indicate that custom request router + # feature is being used in this cluster. + if ( + self._request_router_class.__name__ + != PowerOfTwoChoicesRequestRouter.__name__ + ): + ServeUsageTag.CUSTOM_REQUEST_ROUTER_USED.record("1") + return self._request_router + + def running_replicas_populated(self) -> bool: + return self._running_replicas_populated + + def update_deployment_targets(self, deployment_target_info: DeploymentTargetInfo): + self._deployment_available = deployment_target_info.is_available + + running_replicas = deployment_target_info.running_replicas + if self.request_router: + self.request_router._update_running_replicas(running_replicas) + else: + # In this case, the request router hasn't been initialized yet. + # Store the running replicas so that we can update the request + # router once it is initialized. + self._running_replicas = running_replicas + self._metrics_manager._update_running_replicas(running_replicas) + + if running_replicas: + self._running_replicas_populated = True + + def update_deployment_config(self, deployment_config: DeploymentConfig): + self._request_router_class = ( + deployment_config.request_router_config.get_request_router_class() + ) + self._request_router_kwargs = ( + deployment_config.request_router_config.request_router_kwargs + ) + self._metrics_manager.update_deployment_config( + deployment_config, + curr_num_replicas=len(self.request_router.curr_replicas), + ) + + async def _resolve_request_arguments( + self, + pr: PendingRequest, + ) -> None: + """Asynchronously resolve and replace top-level request args and kwargs.""" + if pr.resolved: + return + + new_args = list(pr.args) + new_kwargs = pr.kwargs.copy() + + # Map from index -> task for resolving positional arg + resolve_arg_tasks = {} + for i, obj in enumerate(pr.args): + task = await self._resolve_request_arg_func(obj, pr.metadata) + if task is not None: + resolve_arg_tasks[i] = task + + # Map from key -> task for resolving key-word arg + resolve_kwarg_tasks = {} + for k, obj in pr.kwargs.items(): + task = await self._resolve_request_arg_func(obj, pr.metadata) + if task is not None: + resolve_kwarg_tasks[k] = task + + # Gather all argument resolution tasks concurrently. + if resolve_arg_tasks or resolve_kwarg_tasks: + all_tasks = list(resolve_arg_tasks.values()) + list( + resolve_kwarg_tasks.values() + ) + await asyncio.wait(all_tasks) + + # Update new args and new kwargs with resolved arguments + for index, task in resolve_arg_tasks.items(): + new_args[index] = task.result() + for key, task in resolve_kwarg_tasks.items(): + new_kwargs[key] = task.result() + + pr.args = new_args + pr.kwargs = new_kwargs + pr.resolved = True + + def _process_finished_request( + self, + replica_id: ReplicaID, + parent_request_id: str, + response_id: str, + result: Union[Any, RayError], + ): + self._metrics_manager.dec_num_running_requests_for_replica(replica_id) + if isinstance(result, ActorDiedError): + # Replica has died but controller hasn't notified the router yet. + # Don't consider this replica for requests in the future, and retry + # routing request. + if self.request_router: + self.request_router.on_replica_actor_died(replica_id) + logger.warning( + f"{replica_id} will not be considered for future " + "requests because it has died." + ) + elif isinstance(result, ActorUnavailableError): + # There are network issues, or replica has died but GCS is down so + # ActorUnavailableError will be raised until GCS recovers. For the + # time being, invalidate the cache entry so that we don't try to + # send requests to this replica without actively probing, and retry + # routing request. + if self.request_router: + self.request_router.on_replica_actor_unavailable(replica_id) + logger.warning( + f"Request failed because {replica_id} is temporarily unavailable." + ) + + async def _route_and_send_request_once( + self, + pr: PendingRequest, + response_id: str, + is_retry: bool, + ) -> Optional[ReplicaResult]: + result: Optional[ReplicaResult] = None + replica: Optional[RunningReplica] = None + try: + num_curr_replicas = len(self.request_router.curr_replicas) + with self._metrics_manager.wrap_queued_request(is_retry, num_curr_replicas): + # If the pending request is uninitialized, we do so by resolving the + # request arguments. This should only be done once per request, and + # should happen after incrementing `num_queued_requests`, so that Serve + # can upscale the downstream deployment while arguments are resolving. + if not pr.resolved: + await self._resolve_request_arguments(pr) + + replica = await self.request_router._choose_replica_for_request( + pr, is_retry=is_retry + ) + + # If the queue len cache is disabled or we're sending a request to Java, + # then directly send the query and hand the response back. The replica will + # never reject requests in this code path. + with_rejection = ( + self._enable_strict_max_ongoing_requests + and not replica.is_cross_language + ) + result = replica.try_send_request(pr, with_rejection=with_rejection) + + # Proactively update the queue length cache. + self.request_router.on_send_request(replica.replica_id) + + # Keep track of requests that have been sent out to replicas + if RAY_SERVE_COLLECT_AUTOSCALING_METRICS_ON_HANDLE: + _request_context = ray.serve.context._get_serve_request_context() + request_id: str = _request_context.request_id + self._metrics_manager.inc_num_running_requests_for_replica( + replica.replica_id + ) + callback = partial( + self._process_finished_request, + replica.replica_id, + request_id, + response_id, + ) + result.add_done_callback(callback) + + if not with_rejection: + return result + + queue_info = await result.get_rejection_response() + self.request_router.on_new_queue_len_info(replica.replica_id, queue_info) + if queue_info.accepted: + self.request_router.on_request_routed(pr, replica.replica_id, result) + return result + + except asyncio.CancelledError: + # NOTE(edoakes): this is not strictly necessary because there are + # currently no `await` statements between getting the ref and returning, + # but I'm adding it defensively. + if result is not None: + result.cancel() + + raise + except ActorDiedError: + # Replica has died but controller hasn't notified the router yet. + # Don't consider this replica for requests in the future, and retry + # routing request. + if replica is not None: + self.request_router.on_replica_actor_died(replica.replica_id) + logger.warning( + f"{replica.replica_id} will not be considered for future " + "requests because it has died." + ) + except ActorUnavailableError: + # There are network issues, or replica has died but GCS is down so + # ActorUnavailableError will be raised until GCS recovers. For the + # time being, invalidate the cache entry so that we don't try to + # send requests to this replica without actively probing, and retry + # routing request. + if replica is not None: + self.request_router.on_replica_actor_unavailable(replica.replica_id) + logger.warning(f"{replica.replica_id} is temporarily unavailable.") + + return None + + async def route_and_send_request( + self, + pr: PendingRequest, + response_id: str, + ) -> ReplicaResult: + """Choose a replica for the request and send it. + + This will block indefinitely if no replicas are available to handle the + request, so it's up to the caller to time out or cancel the request. + """ + # Wait for the router to be initialized before sending the request. + await self._request_router_initialized.wait() + + is_retry = False + while True: + result = await self._route_and_send_request_once( + pr, + response_id, + is_retry, + ) + if result is not None: + return result + + # If the replica rejects the request, retry the routing process. The + # request will be placed on the front of the queue to avoid tail latencies. + # TODO(edoakes): this retry procedure is not perfect because it'll reset the + # process of choosing candidates replicas (i.e., for locality-awareness). + is_retry = True + + async def assign_request( + self, + request_meta: RequestMetadata, + *request_args, + **request_kwargs, + ) -> ReplicaResult: + """Assign a request to a replica and return the resulting object_ref.""" + + if not self._deployment_available: + raise DeploymentUnavailableError(self.deployment_id) + + response_id = generate_request_id() + assign_request_task = asyncio.current_task() + ray.serve.context._add_request_pending_assignment( + request_meta.internal_request_id, response_id, assign_request_task + ) + assign_request_task.add_done_callback( + lambda _: ray.serve.context._remove_request_pending_assignment( + request_meta.internal_request_id, response_id + ) + ) + + # Wait for the router to be initialized before sending the request. + await self._request_router_initialized.wait() + + with self._metrics_manager.wrap_request_assignment(request_meta): + replica_result = None + try: + replica_result = await self.route_and_send_request( + PendingRequest( + args=list(request_args), + kwargs=request_kwargs, + metadata=request_meta, + ), + response_id, + ) + return replica_result + except asyncio.CancelledError: + # NOTE(edoakes): this is not strictly necessary because + # there are currently no `await` statements between + # getting the ref and returning, but I'm adding it defensively. + if replica_result is not None: + replica_result.cancel() + + raise + + async def shutdown(self): + await self._metrics_manager.shutdown() + + +class SingletonThreadRouter(Router): + """Wrapper class that runs an AsyncioRouter on a separate thread. + + The motivation for this is to avoid user code blocking the event loop and + preventing the router from making progress. + + Maintains a singleton event loop running in a daemon thread that is shared by + all AsyncioRouters. + """ + + _asyncio_loop: Optional[asyncio.AbstractEventLoop] = None + _asyncio_loop_creation_lock = threading.Lock() + + def __init__(self, **passthrough_kwargs): + assert ( + "event_loop" not in passthrough_kwargs + ), "SingletonThreadRouter manages the router event loop." + + self._asyncio_router = AsyncioRouter( + event_loop=self._get_singleton_asyncio_loop(), **passthrough_kwargs + ) + + @classmethod + def _get_singleton_asyncio_loop(cls) -> asyncio.AbstractEventLoop: + """Get singleton asyncio loop running in a daemon thread. + + This method is thread safe. + """ + with cls._asyncio_loop_creation_lock: + if cls._asyncio_loop is None: + cls._asyncio_loop = asyncio.new_event_loop() + thread = threading.Thread( + daemon=True, + target=cls._asyncio_loop.run_forever, + ) + thread.start() + + return cls._asyncio_loop + + def running_replicas_populated(self) -> bool: + return self._asyncio_router.running_replicas_populated() + + def assign_request( + self, + request_meta: RequestMetadata, + *request_args, + **request_kwargs, + ) -> concurrent.futures.Future[ReplicaResult]: + """Routes assign_request call on the internal asyncio loop. + + This method uses `run_coroutine_threadsafe` to execute the actual request + assignment logic (`_asyncio_router.assign_request`) on the dedicated + asyncio event loop thread. It returns a `concurrent.futures.Future` that + can be awaited or queried from the calling thread. + + Returns: + A concurrent.futures.Future resolving to the ReplicaResult representing + the assigned request. + """ + + def asyncio_future_callback( + asyncio_future: asyncio.Future, concurrent_future: concurrent.futures.Future + ): + """Callback attached to the asyncio Task running assign_request. + + This runs when the asyncio Task finishes (completes, fails, or is cancelled). + Its primary goal is to propagate cancellation initiated via the + `concurrent_future` back to the `ReplicaResult` in situations where + asyncio_future didn't see the cancellation event in time. Think of it + like a second line of defense for cancellation of replica results. + """ + # Check if the cancellation originated from the concurrent.futures.Future + if ( + concurrent_future.cancelled() + and not asyncio_future.cancelled() + and asyncio_future.exception() is None + ): + result: ReplicaResult = asyncio_future.result() + logger.info( + "Asyncio task completed despite cancellation attempt. " + "Attempting to cancel the request that was assigned to a replica." + ) + result.cancel() + + concurrent_future = concurrent.futures.Future() + + def create_task_and_setup(): + task = self._asyncio_loop.create_task( + self._asyncio_router.assign_request( + request_meta, *request_args, **request_kwargs + ) + ) + + # Set up your cancellation callback + task.add_done_callback( + lambda _: asyncio_future_callback(_, concurrent_future) + ) + + try: + # chain the two futures to handle direction channel of cancellation + futures._chain_future( + ensure_future(task, loop=self._asyncio_loop), concurrent_future + ) + except (SystemExit, KeyboardInterrupt): + raise + except BaseException as exc: + if concurrent_future.set_running_or_notify_cancel(): + concurrent_future.set_exception(exc) + raise + + # Schedule on the event loop thread + self._asyncio_loop.call_soon_threadsafe(create_task_and_setup) + return concurrent_future + + def shutdown(self) -> concurrent.futures.Future: + return asyncio.run_coroutine_threadsafe( + self._asyncio_router.shutdown(), loop=self._asyncio_loop + ) + + +class SharedRouterLongPollClient: + def __init__(self, controller_handle: ActorHandle, event_loop: AbstractEventLoop): + self.controller_handler = controller_handle + self.event_loop = event_loop + + # We use a WeakSet to store the Routers so that we don't prevent them + # from being garbage-collected. + self.routers: MutableMapping[ + DeploymentID, weakref.WeakSet[AsyncioRouter] + ] = defaultdict(weakref.WeakSet) + + # Creating the LongPollClient implicitly starts it + self.long_poll_client = LongPollClient( + controller_handle, + key_listeners={}, + call_in_event_loop=self.event_loop, + ) + + @classmethod + @lru_cache(maxsize=None) + def get_or_create( + cls, controller_handle: ActorHandle, event_loop: AbstractEventLoop + ) -> "SharedRouterLongPollClient": + shared = cls(controller_handle=controller_handle, event_loop=event_loop) + logger.info(f"Started {shared}.") + return shared + + def update_deployment_targets( + self, + deployment_target_info: DeploymentTargetInfo, + deployment_id: DeploymentID, + ) -> None: + for router in self.routers[deployment_id]: + router.update_deployment_targets(deployment_target_info) + router.long_poll_client.stop() + + def update_deployment_config( + self, deployment_config: DeploymentConfig, deployment_id: DeploymentID + ) -> None: + for router in self.routers[deployment_id]: + router.update_deployment_config(deployment_config) + router.long_poll_client.stop() + + def register(self, router: AsyncioRouter) -> None: + # We need to run the underlying method in the same event loop that runs + # the long poll loop, because we need to mutate the mapping of routers, + # which are also being iterated over by the key listener callbacks. + # If those happened concurrently in different threads, + # we could get a `RuntimeError: Set changed size during iteration`. + # See https://github.com/ray-project/ray/pull/53613 for more details. + self.event_loop.call_soon_threadsafe(self._register, router) + + def _register(self, router: AsyncioRouter) -> None: + self.routers[router.deployment_id].add(router) + + # Remove the entries for any deployment ids that no longer have any routers. + # The WeakSets will automatically lose track of Routers that get GC'd, + # but the outer dict will keep the key around, so we need to clean up manually. + # Note the list(...) to avoid mutating self.routers while iterating over it. + for deployment_id, routers in list(self.routers.items()): + if not routers: + self.routers.pop(deployment_id) + + # Register the new listeners on the long poll client. + # Some of these listeners may already exist, but it's safe to add them again. + key_listeners = { + (LongPollNamespace.DEPLOYMENT_TARGETS, deployment_id): partial( + self.update_deployment_targets, deployment_id=deployment_id + ) + for deployment_id in self.routers.keys() + } | { + (LongPollNamespace.DEPLOYMENT_CONFIG, deployment_id): partial( + self.update_deployment_config, deployment_id=deployment_id + ) + for deployment_id in self.routers.keys() + } + self.long_poll_client.add_key_listeners(key_listeners) + + +class CurrentLoopRouter(Router): + """Wrapper class that runs an AsyncioRouter on the current asyncio loop. + Note that this class is NOT THREAD-SAFE, and all methods are expected to be + invoked from a single asyncio event loop. + """ + + def __init__(self, **passthrough_kwargs): + assert ( + "event_loop" not in passthrough_kwargs + ), "CurrentLoopRouter uses the current event loop." + + self._asyncio_loop = asyncio.get_running_loop() + self._asyncio_router = AsyncioRouter( + event_loop=self._asyncio_loop, + _request_router_initialized_event=asyncio.Event(), + **passthrough_kwargs, + ) + + def running_replicas_populated(self) -> bool: + return self._asyncio_router.running_replicas_populated() + + def assign_request( + self, + request_meta: RequestMetadata, + *request_args, + **request_kwargs, + ) -> asyncio.Future[ReplicaResult]: + return self._asyncio_loop.create_task( + self._asyncio_router.assign_request( + request_meta, *request_args, **request_kwargs + ), + ) + + def shutdown(self) -> asyncio.Future: + return self._asyncio_loop.create_task(self._asyncio_router.shutdown()) diff --git a/lib/python3.12/site-packages/ray/serve/_private/storage/__init__.py b/lib/python3.12/site-packages/ray/serve/_private/storage/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lib/python3.12/site-packages/ray/serve/_private/storage/__pycache__/__init__.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/_private/storage/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6e87c67b2cd0c6cbc1fd83d7dccab23f11837bcd Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/_private/storage/__pycache__/__init__.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/_private/storage/__pycache__/kv_store.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/_private/storage/__pycache__/kv_store.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..749daf534ca240d970d4592ed6baf4121576e30c Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/_private/storage/__pycache__/kv_store.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/_private/storage/__pycache__/kv_store_base.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/_private/storage/__pycache__/kv_store_base.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..06217708cc95f9139b4d679c934410890d03be76 Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/_private/storage/__pycache__/kv_store_base.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/_private/storage/kv_store.py b/lib/python3.12/site-packages/ray/serve/_private/storage/kv_store.py new file mode 100644 index 0000000000000000000000000000000000000000..edca67b596af5e2ac40208143f30bcb1174cf6e1 --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/_private/storage/kv_store.py @@ -0,0 +1,109 @@ +import logging +from typing import Optional + +import ray +import ray.serve._private.constants as serve_constants +from ray._raylet import GcsClient +from ray.serve._private.storage.kv_store_base import KVStoreBase + +logger = logging.getLogger(serve_constants.SERVE_LOGGER_NAME) + +SERVE_INTERNAL_KV_NAMESPACE = b"serve" + + +def get_storage_key(namespace: str, storage_key: str) -> str: + """In case we need to access kvstore""" + return "{ns}-{key}".format(ns=namespace, key=storage_key) + + +class KVStoreError(Exception): + def __init__(self, rpc_code): + self.rpc_code = rpc_code + + +class RayInternalKVStore(KVStoreBase): + """Wraps ray's internal_kv with a namespace to avoid collisions. + + Supports string keys and bytes values, caller must handle serialization. + """ + + def __init__( + self, + namespace: Optional[str] = None, + gcs_client: Optional[GcsClient] = None, + ): + if namespace is not None and not isinstance(namespace, str): + raise TypeError("namespace must a string, got: {}.".format(type(namespace))) + if gcs_client is not None: + self.gcs_client = gcs_client + else: + self.gcs_client = GcsClient(address=ray.get_runtime_context().gcs_address) + self.timeout = serve_constants.RAY_SERVE_KV_TIMEOUT_S + self.namespace = namespace or "" + + def get_storage_key(self, key: str) -> str: + return "{ns}-{key}".format(ns=self.namespace, key=key) + + def put(self, key: str, val: bytes) -> bool: + """Put the key-value pair into the store. + + Args: + key: The key to store. + val: The value to store. + """ + if not isinstance(key, str): + raise TypeError("key must be a string, got: {}.".format(type(key))) + if not isinstance(val, bytes): + raise TypeError("val must be bytes, got: {}.".format(type(val))) + + try: + return self.gcs_client.internal_kv_put( + self.get_storage_key(key).encode(), + val, + overwrite=True, + namespace=SERVE_INTERNAL_KV_NAMESPACE, + timeout=self.timeout, + ) + except ray.exceptions.RpcError as e: + raise KVStoreError(e.rpc_code) + + def get(self, key: str) -> Optional[bytes]: + """Get the value associated with the given key from the store. + + Args: + key: The key to retrieve. + + Returns: + Optional[bytes]: The bytes value. If the key wasn't found, returns None. + """ + if not isinstance(key, str): + raise TypeError("key must be a string, got: {}.".format(type(key))) + + try: + return self.gcs_client.internal_kv_get( + self.get_storage_key(key).encode(), + namespace=SERVE_INTERNAL_KV_NAMESPACE, + timeout=self.timeout, + ) + except ray.exceptions.RpcError as e: + raise KVStoreError(e.rpc_code) + + def delete(self, key: str): + """Delete the value associated with the given key from the store. + + Args: + key: The key to delete. + """ + + if not isinstance(key, str): + raise TypeError("key must be a string, got: {}.".format(type(key))) + + try: + return self.gcs_client.internal_kv_del( + self.get_storage_key(key).encode(), + False, + namespace=SERVE_INTERNAL_KV_NAMESPACE, + timeout=self.timeout, + ) + except ray.exceptions.RpcError as e: + raise KVStoreError(e.rpc_code) diff --git a/lib/python3.12/site-packages/ray/serve/_private/storage/kv_store_base.py b/lib/python3.12/site-packages/ray/serve/_private/storage/kv_store_base.py new file mode 100644 index 0000000000000000000000000000000000000000..68216e6547d169963f424c726fd2494a774c0039 --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/_private/storage/kv_store_base.py @@ -0,0 +1,56 @@ +import abc +from abc import abstractmethod +from typing import Optional + +from ray.util.annotations import DeveloperAPI + + +@DeveloperAPI +class KVStoreBase(metaclass=abc.ABCMeta): + """Abstract class for KVStore defining APIs needed for ray serve + use cases, currently (8/6/2021) controller state checkpointing. + """ + + @abstractmethod + def get_storage_key(self, key: str) -> str: + """Get internal key for storage. + + Args: + key: User provided key + + Returns: + storage_key: Formatted key for storage, usually by + prepending namespace. + """ + raise NotImplementedError("get_storage_key() has to be implemented") + + @abstractmethod + def put(self, key: str, val: bytes) -> bool: + """Put object into kv store, bytes only. + + Args: + key: Key for object to be stored. + val: Byte value of object. + """ + raise NotImplementedError("put() has to be implemented") + + @abstractmethod + def get(self, key: str) -> Optional[bytes]: + """Get object from storage. + + Args: + key: Key for object to be retrieved. + + Returns: + val: Byte value of object from storage. + """ + raise NotImplementedError("get() has to be implemented") + + @abstractmethod + def delete(self, key: str) -> None: + """Delete an object. + + Args: + key: Key for object to be deleted. + """ + raise NotImplementedError("delete() has to be implemented") diff --git a/lib/python3.12/site-packages/ray/serve/_private/task_consumer.py b/lib/python3.12/site-packages/ray/serve/_private/task_consumer.py new file mode 100644 index 0000000000000000000000000000000000000000..b8d61ec25c60356d86743ef4f75517bac0d39a7c --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/_private/task_consumer.py @@ -0,0 +1,12 @@ +from abc import ABC + + +class TaskConsumerWrapper(ABC): + def __init__(self, *args, **kwargs): + pass + + def initialize_callable(self, consumer_concurrency: int): + pass + + def __del__(self): + pass diff --git a/lib/python3.12/site-packages/ray/serve/_private/test_utils.py b/lib/python3.12/site-packages/ray/serve/_private/test_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..e503965483cb27666bfe05fb822ece94badbe8bf --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/_private/test_utils.py @@ -0,0 +1,864 @@ +import asyncio +import datetime +import os +import random +import threading +import time +from contextlib import asynccontextmanager +from copy import copy, deepcopy +from typing import Any, Callable, Dict, List, Optional, Tuple, Union + +import grpc +import httpx +import requests +from starlette.requests import Request + +import ray +from ray import serve +from ray._common.network_utils import build_address +from ray._common.test_utils import wait_for_condition +from ray.actor import ActorHandle +from ray.serve._private.client import ServeControllerClient +from ray.serve._private.common import ( + CreatePlacementGroupRequest, + DeploymentID, + DeploymentStatus, + RequestProtocol, +) +from ray.serve._private.constants import ( + SERVE_DEFAULT_APP_NAME, + SERVE_NAMESPACE, +) +from ray.serve._private.deployment_state import ALL_REPLICA_STATES, ReplicaState +from ray.serve._private.proxy import DRAINING_MESSAGE +from ray.serve._private.usage import ServeUsageTag +from ray.serve._private.utils import TimerBase +from ray.serve.context import _get_global_client +from ray.serve.generated import serve_pb2, serve_pb2_grpc +from ray.serve.schema import ApplicationStatus, TargetGroup +from ray.util.state import list_actors + +TELEMETRY_ROUTE_PREFIX = "/telemetry" +STORAGE_ACTOR_NAME = "storage" + + +class MockTimer(TimerBase): + def __init__(self, start_time: Optional[float] = None): + self._lock = threading.Lock() + self.reset(start_time=start_time) + + def reset(self, start_time: Optional[float] = None): + if start_time is None: + start_time = time.time() + self._curr = start_time + + def time(self) -> float: + return self._curr + + def advance(self, by: float): + with self._lock: + self._curr += by + + def realistic_sleep(self, amt: float): + with self._lock: + self._curr += amt + 0.001 + + +class MockAsyncTimer: + def __init__(self, start_time: Optional[float] = 0): + self.reset(start_time=start_time) + self._num_sleepers = 0 + + def reset(self, start_time: 0): + self._curr = start_time + + def time(self) -> float: + return self._curr + + async def sleep(self, amt: float): + self._num_sleepers += 1 + end = self._curr + amt + + # Give up the event loop + while self._curr < end: + await asyncio.sleep(0) + + self._num_sleepers -= 1 + + def advance(self, amt: float): + self._curr += amt + + def num_sleepers(self): + return self._num_sleepers + + +class MockKVStore: + def __init__(self): + self.store = dict() + + def put(self, key: str, val: Any) -> bool: + if not isinstance(key, str): + raise TypeError("key must be a string, got: {}.".format(type(key))) + self.store[key] = val + return True + + def get(self, key: str) -> Any: + if not isinstance(key, str): + raise TypeError("key must be a string, got: {}.".format(type(key))) + return self.store.get(key, None) + + def delete(self, key: str) -> bool: + if not isinstance(key, str): + raise TypeError("key must be a string, got: {}.".format(type(key))) + + if key in self.store: + del self.store[key] + return True + + return False + + +class MockClusterNodeInfoCache: + def __init__(self): + self.alive_node_ids = set() + self.total_resources_per_node = dict() + self.available_resources_per_node = dict() + self.draining_nodes = dict() + self.node_labels = dict() + + def get_alive_node_ids(self): + return self.alive_node_ids + + def get_draining_nodes(self): + return self.draining_nodes + + def get_active_node_ids(self): + return self.alive_node_ids - set(self.draining_nodes) + + def get_node_az(self, node_id): + return None + + def get_available_resources_per_node(self): + return self.available_resources_per_node + + def get_total_resources_per_node(self): + return self.total_resources_per_node + + def add_node(self, node_id: str, resources: Dict = None, labels: Dict = None): + self.alive_node_ids.add(node_id) + self.total_resources_per_node[node_id] = deepcopy(resources) or {} + self.available_resources_per_node[node_id] = deepcopy(resources) or {} + self.node_labels[node_id] = labels or {} + + def set_available_resources_per_node(self, node_id: str, resources: Dict): + self.available_resources_per_node[node_id] = deepcopy(resources) + + +class FakeRemoteFunction: + def remote(self): + pass + + +class MockActorHandle: + def __init__(self, **kwargs): + self._options = kwargs + self._actor_id = "fake_id" + self.initialize_and_get_metadata_called = False + self.is_allocated_called = False + + @property + def initialize_and_get_metadata(self): + self.initialize_and_get_metadata_called = True + # return a mock object so that we can call `remote()` on it. + return FakeRemoteFunction() + + @property + def is_allocated(self): + self.is_allocated_called = True + return FakeRemoteFunction() + + +class MockActorClass: + def __init__(self): + self._init_args = () + self._options = dict() + + def options(self, **kwargs): + res = copy(self) + + for k, v in kwargs.items(): + res._options[k] = v + + return res + + def remote(self, *args) -> MockActorHandle: + return MockActorHandle(init_args=args, **self._options) + + +class MockPlacementGroup: + def __init__(self, request: CreatePlacementGroupRequest): + self._bundles = request.bundles + self._strategy = request.strategy + self._soft_target_node_id = request.target_node_id + self._name = request.name + self._lifetime = "detached" + + +class MockDeploymentHandle: + def __init__(self, deployment_name: str, app_name: str = SERVE_DEFAULT_APP_NAME): + self._deployment_name = deployment_name + self._app_name = app_name + self._protocol = RequestProtocol.UNDEFINED + self._running_replicas_populated = False + self._initialized = False + + def is_initialized(self): + return self._initialized + + def _init(self): + if self._initialized: + raise RuntimeError("already initialized") + + self._initialized = True + + def options(self, *args, **kwargs): + return self + + def __eq__(self, dep: Tuple[str]): + other_deployment_name, other_app_name = dep + return ( + self._deployment_name == other_deployment_name + and self._app_name == other_app_name + ) + + def _set_request_protocol(self, protocol: RequestProtocol): + self._protocol = protocol + + def _get_or_create_router(self): + pass + + def running_replicas_populated(self) -> bool: + return self._running_replicas_populated + + def set_running_replicas_populated(self, val: bool): + self._running_replicas_populated = val + + +@serve.deployment +class GetPID: + def __call__(self): + return os.getpid() + + +get_pid_entrypoint = GetPID.bind() + + +def check_ray_stopped(): + try: + requests.get("http://localhost:8265/api/ray/version") + return False + except Exception: + return True + + +def check_ray_started(): + return requests.get("http://localhost:8265/api/ray/version").status_code == 200 + + +def check_deployment_status( + name: str, expected_status: DeploymentStatus, app_name=SERVE_DEFAULT_APP_NAME +) -> bool: + app_status = serve.status().applications[app_name] + assert app_status.deployments[name].status == expected_status + return True + + +def get_num_alive_replicas( + deployment_name: str, app_name: str = SERVE_DEFAULT_APP_NAME +) -> int: + """Get the replicas currently running for the given deployment.""" + + dep_id = DeploymentID(name=deployment_name, app_name=app_name) + actors = list_actors( + filters=[ + ("class_name", "=", dep_id.to_replica_actor_class_name()), + ("state", "=", "ALIVE"), + ] + ) + return len(actors) + + +def check_num_replicas_gte( + name: str, target: int, app_name: str = SERVE_DEFAULT_APP_NAME +) -> int: + """Check if num replicas is >= target.""" + + assert get_num_alive_replicas(name, app_name) >= target + return True + + +def check_num_replicas_eq( + name: str, + target: int, + app_name: str = SERVE_DEFAULT_APP_NAME, + use_controller: bool = False, +) -> int: + """Check if num replicas is == target.""" + + if use_controller: + dep = serve.status().applications[app_name].deployments[name] + num_running_replicas = dep.replica_states.get(ReplicaState.RUNNING, 0) + assert num_running_replicas == target + else: + assert get_num_alive_replicas(name, app_name) == target + + return True + + +def check_num_replicas_lte( + name: str, target: int, app_name: str = SERVE_DEFAULT_APP_NAME +) -> int: + """Check if num replicas is <= target.""" + + assert get_num_alive_replicas(name, app_name) <= target + return True + + +def check_apps_running(apps: List): + status = serve.status() + + for app_name in apps: + assert status.applications[app_name].status == ApplicationStatus.RUNNING + + return True + + +def check_replica_counts( + controller: ActorHandle, + deployment_id: DeploymentID, + total: Optional[int] = None, + by_state: Optional[List[Tuple[ReplicaState, int, Callable]]] = None, +): + """Uses _dump_replica_states_for_testing to check replica counts. + + Args: + controller: A handle to the Serve controller. + deployment_id: The deployment to check replica counts for. + total: The total number of expected replicas for the deployment. + by_state: A list of tuples of the form + (replica state, number of replicas, filter function). + Used for more fine grained checks. + """ + replicas = ray.get( + controller._dump_replica_states_for_testing.remote(deployment_id) + ) + + if total is not None: + replica_counts = { + state: len(replicas.get([state])) + for state in ALL_REPLICA_STATES + if replicas.get([state]) + } + assert replicas.count() == total, replica_counts + + if by_state is not None: + for state, count, check in by_state: + assert isinstance(state, ReplicaState) + assert isinstance(count, int) and count >= 0 + if check: + filtered = {r for r in replicas.get(states=[state]) if check(r)} + curr_count = len(filtered) + else: + curr_count = replicas.count(states=[state]) + msg = f"Expected {count} for state {state} but got {curr_count}." + assert curr_count == count, msg + + return True + + +@ray.remote(name=STORAGE_ACTOR_NAME, namespace=SERVE_NAMESPACE, num_cpus=0) +class TelemetryStorage: + def __init__(self): + self.reports_received = 0 + self.current_report = dict() + + def store_report(self, report: Dict) -> None: + self.reports_received += 1 + self.current_report = report + + def get_report(self) -> Dict: + return self.current_report + + def get_reports_received(self) -> int: + return self.reports_received + + +@serve.deployment(ray_actor_options={"num_cpus": 0}) +class TelemetryReceiver: + def __init__(self): + self.storage = ray.get_actor(name=STORAGE_ACTOR_NAME, namespace=SERVE_NAMESPACE) + + async def __call__(self, request: Request) -> bool: + report = await request.json() + ray.get(self.storage.store_report.remote(report)) + return True + + +receiver_app = TelemetryReceiver.bind() + + +def start_telemetry_app(): + """Start a telemetry Serve app. + + Ray should be initialized before calling this method. + + NOTE: If you're running the TelemetryReceiver Serve app to check telemetry, + remember that the receiver itself is counted in the telemetry. E.g. if you + deploy a Serve app other than the receiver, the number of apps in the + cluster is 2- not 1– since the receiver is also running. + + Returns a handle to a TelemetryStorage actor. You can use this actor + to access the latest telemetry reports. + """ + + storage = TelemetryStorage.remote() + serve.run(receiver_app, name="telemetry", route_prefix=TELEMETRY_ROUTE_PREFIX) + return storage + + +def check_telemetry( + tag: ServeUsageTag, expected: Any, storage_actor_name: str = STORAGE_ACTOR_NAME +): + storage_handle = ray.get_actor(storage_actor_name, namespace=SERVE_NAMESPACE) + report = ray.get(storage_handle.get_report.remote()) + print(report["extra_usage_tags"]) + assert tag.get_value_from_report(report) == expected + return True + + +def ping_grpc_list_applications(channel, app_names, test_draining=False): + import pytest + + stub = serve_pb2_grpc.RayServeAPIServiceStub(channel) + request = serve_pb2.ListApplicationsRequest() + if test_draining: + with pytest.raises(grpc.RpcError) as exception_info: + _, _ = stub.ListApplications.with_call(request=request) + rpc_error = exception_info.value + assert rpc_error.code() == grpc.StatusCode.UNAVAILABLE + assert rpc_error.details() == DRAINING_MESSAGE + else: + response, call = stub.ListApplications.with_call(request=request) + assert call.code() == grpc.StatusCode.OK + assert response.application_names == app_names + return True + + +def ping_grpc_healthz(channel, test_draining=False): + import pytest + + stub = serve_pb2_grpc.RayServeAPIServiceStub(channel) + request = serve_pb2.HealthzRequest() + if test_draining: + with pytest.raises(grpc.RpcError) as exception_info: + _, _ = stub.Healthz.with_call(request=request) + rpc_error = exception_info.value + assert rpc_error.code() == grpc.StatusCode.UNAVAILABLE + assert rpc_error.details() == DRAINING_MESSAGE + else: + response, call = stub.Healthz.with_call(request=request) + assert call.code() == grpc.StatusCode.OK + assert response.message == "success" + + +def ping_grpc_call_method(channel, app_name, test_not_found=False): + import pytest + + stub = serve_pb2_grpc.UserDefinedServiceStub(channel) + request = serve_pb2.UserDefinedMessage(name="foo", num=30, foo="bar") + metadata = (("application", app_name),) + if test_not_found: + with pytest.raises(grpc.RpcError) as exception_info: + _, _ = stub.__call__.with_call(request=request, metadata=metadata) + rpc_error = exception_info.value + assert rpc_error.code() == grpc.StatusCode.NOT_FOUND, rpc_error.code() + assert f"Application '{app_name}' not found." in rpc_error.details() + else: + response, call = stub.__call__.with_call(request=request, metadata=metadata) + assert call.code() == grpc.StatusCode.OK, call.code() + assert response.greeting == "Hello foo from bar", response.greeting + + +def ping_grpc_another_method(channel, app_name): + stub = serve_pb2_grpc.UserDefinedServiceStub(channel) + request = serve_pb2.UserDefinedMessage(name="foo", num=30, foo="bar") + metadata = (("application", app_name),) + response = stub.Method1(request=request, metadata=metadata) + assert response.greeting == "Hello foo from method1" + + +def ping_grpc_model_multiplexing(channel, app_name): + stub = serve_pb2_grpc.UserDefinedServiceStub(channel) + request = serve_pb2.UserDefinedMessage(name="foo", num=30, foo="bar") + multiplexed_model_id = "999" + metadata = ( + ("application", app_name), + ("multiplexed_model_id", multiplexed_model_id), + ) + response = stub.Method2(request=request, metadata=metadata) + assert ( + response.greeting + == f"Method2 called model, loading model: {multiplexed_model_id}" + ) + + +def ping_grpc_streaming(channel, app_name): + stub = serve_pb2_grpc.UserDefinedServiceStub(channel) + request = serve_pb2.UserDefinedMessage(name="foo", num=30, foo="bar") + metadata = (("application", app_name),) + responses = stub.Streaming(request=request, metadata=metadata) + for idx, response in enumerate(responses): + assert response.greeting == f"{idx}: Hello foo from bar" + + +def ping_fruit_stand(channel, app_name): + stub = serve_pb2_grpc.FruitServiceStub(channel) + request = serve_pb2.FruitAmounts(orange=4, apple=8) + metadata = (("application", app_name),) + response = stub.FruitStand(request=request, metadata=metadata) + assert response.costs == 32 + + +@asynccontextmanager +async def send_signal_on_cancellation(signal_actor: ActorHandle): + cancelled = False + try: + yield + await asyncio.sleep(100) + except asyncio.CancelledError: + cancelled = True + # Clear the context var to avoid Ray recursively cancelling this method call. + ray._raylet.async_task_id.set(None) + await signal_actor.send.remote() + + if not cancelled: + raise RuntimeError( + "CancelledError wasn't raised during `send_signal_on_cancellation` block" + ) + + +class FakeGrpcContext: + def __init__(self): + self._auth_context = {"key": "value"} + self._invocation_metadata = [("key", "value")] + self._peer = "peer" + self._peer_identities = b"peer_identities" + self._peer_identity_key = "peer_identity_key" + self._code = None + self._details = None + self._trailing_metadata = [] + self._invocation_metadata = [] + + def auth_context(self): + return self._auth_context + + def code(self): + return self._code + + def details(self): + return self._details + + def peer(self): + return self._peer + + def peer_identities(self): + return self._peer_identities + + def peer_identity_key(self): + return self._peer_identity_key + + def trailing_metadata(self): + return self._trailing_metadata + + def set_code(self, code): + self._code = code + + def set_details(self, details): + self._details = details + + def set_trailing_metadata(self, trailing_metadata): + self._trailing_metadata = trailing_metadata + + def invocation_metadata(self): + return self._invocation_metadata + + +class FakeGauge: + def __init__(self, name: str = None, tag_keys: Tuple[str] = None): + self.name = name + self.values = dict() + + self.tags = tag_keys or () + self.default_tags = dict() + + def set_default_tags(self, tags: Dict[str, str]): + for key, tag in tags.items(): + assert key in self.tags + self.default_tags[key] = tag + + def set(self, value: Union[int, float], tags: Dict[str, str] = None): + merged_tags = self.default_tags.copy() + merged_tags.update(tags or {}) + assert set(merged_tags.keys()) == set(self.tags) + + d = self.values + for tag in self.tags[:-1]: + tag_value = merged_tags[tag] + if tag_value not in d: + d[tag_value] = dict() + d = d[tag_value] + + d[merged_tags[self.tags[-1]]] = value + + def get_value(self, tags: Dict[str, str]): + value = self.values + for tag in self.tags: + tag_value = tags[tag] + value = value.get(tag_value) + if value is None: + return + + return value + + +class FakeCounter: + def __init__(self, name: str = None, tag_keys: Tuple[str] = None): + self.name = name + self.counts = dict() + + self.tags = tag_keys or () + self.default_tags = dict() + + def set_default_tags(self, tags: Dict[str, str]): + for key, tag in tags.items(): + assert key in self.tags + self.default_tags[key] = tag + + def inc(self, value: Union[int, float] = 1.0, tags: Dict[str, str] = None): + merged_tags = self.default_tags.copy() + merged_tags.update(tags or {}) + assert set(merged_tags.keys()) == set(self.tags) + + d = self.counts + for tag in self.tags[:-1]: + tag_value = merged_tags[tag] + if tag_value not in d: + d[tag_value] = dict() + d = d[tag_value] + + key = merged_tags[self.tags[-1]] + d[key] = d.get(key, 0) + value + + def get_count(self, tags: Dict[str, str]) -> int: + value = self.counts + for tag in self.tags: + tag_value = tags[tag] + value = value.get(tag_value) + if value is None: + return + + return value + + def get_tags(self): + return self.tags + + +@ray.remote +def get_node_id(): + return ray.get_runtime_context().get_node_id() + + +def check_num_alive_nodes(target: int): + alive_nodes = [node for node in ray.nodes() if node["Alive"]] + assert len(alive_nodes) == target + return True + + +def get_deployment_details( + deployment_name: str, + app_name: str = SERVE_DEFAULT_APP_NAME, + _client: ServeControllerClient = None, +): + client = _client or _get_global_client() + details = client.get_serve_details() + return details["applications"][app_name]["deployments"][deployment_name] + + +@ray.remote +class Counter: + def __init__(self, target: int): + self.count = 0 + self.target = target + self.ready_event = asyncio.Event() + + def inc(self): + self.count += 1 + if self.count == self.target: + self.ready_event.set() + + async def wait(self): + await self.ready_event.wait() + + +def tlog(s: str, level: str = "INFO"): + """Convenient logging method for testing.""" + + now = datetime.datetime.now().strftime("%H:%M:%S.%f")[:-3] + print(f"[{level}] {now} {s}") + + +def check_target_groups_ready( + client: ServeControllerClient, + app_name: str, + protocol: Union[str, RequestProtocol] = RequestProtocol.HTTP, +): + """Wait for target groups to be ready for the given app and protocol. + + Target groups are ready when there are at least one target for the given protocol. And it's + possible that target groups are not ready immediately. An example is when the controller + is recovering from a crash. + """ + target_groups = ray.get(client._controller.get_target_groups.remote(app_name)) + target_groups = [ + target_group + for target_group in target_groups + if target_group.protocol == protocol + ] + all_targets = [ + target for target_group in target_groups for target in target_group.targets + ] + return len(all_targets) > 0 + + +def get_application_urls( + protocol: Union[str, RequestProtocol] = RequestProtocol.HTTP, + app_name: str = SERVE_DEFAULT_APP_NAME, + use_localhost: bool = True, + is_websocket: bool = False, + exclude_route_prefix: bool = False, + from_proxy_manager: bool = False, +) -> List[str]: + """Get the URL of the application. + + Args: + protocol: The protocol to use for the application. + app_name: The name of the application. + use_localhost: Whether to use localhost instead of the IP address. + Set to True if Serve deployments are not exposed publicly or + for low latency benchmarking. + is_websocket: Whether the url should be served as a websocket. + exclude_route_prefix: The route prefix to exclude from the application. + from_proxy_manager: Whether the caller is a proxy manager. + Returns: + The URLs of the application. + """ + client = _get_global_client(_health_check_controller=True) + serve_details = client.get_serve_details() + assert ( + app_name in serve_details["applications"] + ), f"App {app_name} not found in serve details. Use this method only when the app is known to be running." + route_prefix = serve_details["applications"][app_name]["route_prefix"] + # route_prefix is set to None when route_prefix value is specifically set to None + # in the config used to deploy the app. + if exclude_route_prefix or route_prefix is None: + route_prefix = "" + if isinstance(protocol, str): + protocol = RequestProtocol(protocol) + target_groups: List[TargetGroup] = ray.get( + client._controller.get_target_groups.remote(app_name, from_proxy_manager) + ) + target_groups = [ + target_group + for target_group in target_groups + if target_group.protocol == protocol + ] + if len(target_groups) == 0: + raise ValueError( + f"No target group found for app {app_name} with protocol {protocol} and route prefix {route_prefix}" + ) + urls = [] + for target_group in target_groups: + for target in target_group.targets: + ip = "localhost" if use_localhost else target.ip + if protocol == RequestProtocol.HTTP: + scheme = "ws" if is_websocket else "http" + url = f"{scheme}://{build_address(ip, target.port)}{route_prefix}" + elif protocol == RequestProtocol.GRPC: + if is_websocket: + raise ValueError( + "is_websocket=True is not supported with gRPC protocol." + ) + url = build_address(ip, target.port) + else: + raise ValueError(f"Unsupported protocol: {protocol}") + url = url.rstrip("/") + urls.append(url) + return urls + + +def get_application_url( + protocol: Union[str, RequestProtocol] = RequestProtocol.HTTP, + app_name: str = SERVE_DEFAULT_APP_NAME, + use_localhost: bool = True, + is_websocket: bool = False, + exclude_route_prefix: bool = False, + from_proxy_manager: bool = False, +) -> str: + """Get the URL of the application. + + Args: + protocol: The protocol to use for the application. + app_name: The name of the application. + use_localhost: Whether to use localhost instead of the IP address. + Set to True if Serve deployments are not exposed publicly or + for low latency benchmarking. + is_websocket: Whether the url should be served as a websocket. + exclude_route_prefix: The route prefix to exclude from the application. + from_proxy_manager: Whether the caller is a proxy manager. + Returns: + The URL of the application. If there are multiple URLs, a random one is returned. + """ + return random.choice( + get_application_urls( + protocol, + app_name, + use_localhost, + is_websocket, + exclude_route_prefix, + from_proxy_manager, + ) + ) + + +def check_running(app_name: str = SERVE_DEFAULT_APP_NAME): + assert serve.status().applications[app_name].status == ApplicationStatus.RUNNING + return True + + +def request_with_retries(timeout=30, app_name=SERVE_DEFAULT_APP_NAME): + result_holder = {"resp": None} + + def _attempt() -> bool: + try: + url = get_application_url("HTTP", app_name=app_name) + result_holder["resp"] = httpx.get(url, timeout=timeout) + return True + except (httpx.RequestError, IndexError): + return False + + try: + wait_for_condition(_attempt, timeout=timeout) + return result_holder["resp"] + except RuntimeError as e: + # Preserve previous API by raising TimeoutError on expiry + raise TimeoutError from e diff --git a/lib/python3.12/site-packages/ray/serve/_private/thirdparty/__init__.py b/lib/python3.12/site-packages/ray/serve/_private/thirdparty/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lib/python3.12/site-packages/ray/serve/_private/thirdparty/__pycache__/__init__.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/_private/thirdparty/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..586ac60c3a8365186d4cf1f1653f7dd205b17571 Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/_private/thirdparty/__pycache__/__init__.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/_private/thirdparty/__pycache__/get_asgi_route_name.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/_private/thirdparty/__pycache__/get_asgi_route_name.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9469a9d0b802548201c12640432d9ef3886f4551 Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/_private/thirdparty/__pycache__/get_asgi_route_name.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/_private/thirdparty/get_asgi_route_name.py b/lib/python3.12/site-packages/ray/serve/_private/thirdparty/get_asgi_route_name.py new file mode 100644 index 0000000000000000000000000000000000000000..b73e595a5353cda818e789e0cabfc2444515e116 --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/_private/thirdparty/get_asgi_route_name.py @@ -0,0 +1,140 @@ +# This code is adapted from: +# https://github.com/elastic/apm-agent-python/blob/f570e8c2b68a8714628acac815aebcc3518b44c7/elasticapm/contrib/starlette/__init__.py +# +# BSD 3-Clause License +# +# Copyright (c) 2012, the Sentry Team, see AUTHORS for more details +# Copyright (c) 2019, Elasticsearch BV +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# * Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from +# this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + +from typing import List, Optional, Set + +from starlette.routing import Match, Mount, Route +from starlette.types import ASGIApp, Scope + + +def _get_route_name( + scope: Scope, routes: List[Route], *, route_name: Optional[str] = None +) -> Optional[str]: + for route in routes: + match, child_scope = route.matches(scope) + if match == Match.FULL: + route_name = route.path + child_scope = {**scope, **child_scope} + if isinstance(route, Mount) and route.routes: + child_route_name = _get_route_name( + child_scope, route.routes, route_name=route_name + ) + if child_route_name is None: + route_name = None + else: + route_name += child_route_name + return route_name + elif match == Match.PARTIAL and route_name is None: + route_name = route.path + + return None + + +def get_asgi_route_name(app: ASGIApp, scope: Scope) -> Optional[str]: + """Gets route name for given request taking mounts into account.""" + + routes = app.routes + route_name = _get_route_name(scope, routes) + + # Starlette magically redirects requests if the path matches a route name + # with a trailing slash appended or removed. To not spam the transaction + # names list, we do the same here and put these redirects all in the + # same "redirect trailing slashes" transaction name. + if not route_name and app.router.redirect_slashes and scope["path"] != "/": + redirect_scope = dict(scope) + if scope["path"].endswith("/"): + redirect_scope["path"] = scope["path"][:-1] + trim = True + else: + redirect_scope["path"] = scope["path"] + "/" + trim = False + + route_name = _get_route_name(redirect_scope, routes) + if route_name is not None: + route_name = route_name + "/" if trim else route_name[:-1] + + if route_name: + root_path = scope.get("root_path", "") + if root_path: + route_name = root_path.rstrip("/") + "/" + route_name.lstrip("/") + + return route_name + + +def extract_route_patterns(app: ASGIApp) -> List[str]: + """Extracts all route patterns from an ASGI app. + + This function recursively traverses the app's routes (including mounted apps) + and returns a list of all route patterns. This is used to communicate available + routes from build time to proxies for accurate metrics tagging. + + Args: + app: The ASGI application (typically FastAPI or Starlette) + + Returns: + List of route patterns, e.g., ["/", "/api/{user_id}", "/items/{item_id}"] + """ + patterns: Set[str] = set() + + def _extract_from_routes(routes: List[Route], prefix: str = "") -> None: + for route in routes: + route_path = prefix + route.path + + if isinstance(route, Mount): + # Recursively extract patterns from mounted apps + if hasattr(route, "routes") and route.routes: + _extract_from_routes(route.routes, route_path) + else: + # Mount without sub-routes + patterns.add(route_path) + else: + # Regular route + patterns.add(route_path) + + try: + if hasattr(app, "routes"): + _extract_from_routes(app.routes) + + # Handle root_path if present + if hasattr(app, "root_path") and app.root_path: + root_path = app.root_path.rstrip("/") + patterns = { + root_path + "/" + p.lstrip("/") if p != "/" else root_path + p + for p in patterns + } + except Exception: + # If extraction fails for any reason, return empty list + # This shouldn't break the system + return [] + + return sorted(patterns) diff --git a/lib/python3.12/site-packages/ray/serve/_private/usage.py b/lib/python3.12/site-packages/ray/serve/_private/usage.py new file mode 100644 index 0000000000000000000000000000000000000000..e0787902994537900f0aa43166f29223e36855ae --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/_private/usage.py @@ -0,0 +1,55 @@ +from enum import Enum +from typing import Dict, Optional + +from ray._common.usage.usage_lib import TagKey, record_extra_usage_tag + + +class ServeUsageTag(Enum): + API_VERSION = TagKey.SERVE_API_VERSION + NUM_DEPLOYMENTS = TagKey.SERVE_NUM_DEPLOYMENTS + GCS_STORAGE = TagKey.GCS_STORAGE + NUM_GPU_DEPLOYMENTS = TagKey.SERVE_NUM_GPU_DEPLOYMENTS + FASTAPI_USED = TagKey.SERVE_FASTAPI_USED + DAG_DRIVER_USED = TagKey.SERVE_DAG_DRIVER_USED + HTTP_ADAPTER_USED = TagKey.SERVE_HTTP_ADAPTER_USED + GRPC_INGRESS_USED = TagKey.SERVE_GRPC_INGRESS_USED + REST_API_VERSION = TagKey.SERVE_REST_API_VERSION + NUM_APPS = TagKey.SERVE_NUM_APPS + NUM_REPLICAS_LIGHTWEIGHT_UPDATED = TagKey.SERVE_NUM_REPLICAS_LIGHTWEIGHT_UPDATED + USER_CONFIG_LIGHTWEIGHT_UPDATED = TagKey.SERVE_USER_CONFIG_LIGHTWEIGHT_UPDATED + AUTOSCALING_CONFIG_LIGHTWEIGHT_UPDATED = ( + TagKey.SERVE_AUTOSCALING_CONFIG_LIGHTWEIGHT_UPDATED + ) + DEPLOYMENT_HANDLE_API_USED = TagKey.SERVE_DEPLOYMENT_HANDLE_API_USED + DEPLOYMENT_HANDLE_TO_OBJECT_REF_API_USED = ( + TagKey.SERVE_DEPLOYMENT_HANDLE_TO_OBJECT_REF_API_USED + ) + MULTIPLEXED_API_USED = TagKey.SERVE_MULTIPLEXED_API_USED + HTTP_PROXY_USED = TagKey.SERVE_HTTP_PROXY_USED + GRPC_PROXY_USED = TagKey.SERVE_GRPC_PROXY_USED + SERVE_STATUS_API_USED = TagKey.SERVE_STATUS_API_USED + SERVE_GET_APP_HANDLE_API_USED = TagKey.SERVE_GET_APP_HANDLE_API_USED + SERVE_GET_DEPLOYMENT_HANDLE_API_USED = TagKey.SERVE_GET_DEPLOYMENT_HANDLE_API_USED + APP_CONTAINER_RUNTIME_ENV_USED = TagKey.SERVE_APP_CONTAINER_RUNTIME_ENV_USED + DEPLOYMENT_CONTAINER_RUNTIME_ENV_USED = ( + TagKey.SERVE_DEPLOYMENT_CONTAINER_RUNTIME_ENV_USED + ) + NUM_NODE_COMPACTIONS = TagKey.SERVE_NUM_NODE_COMPACTIONS + AUTO_NUM_REPLICAS_USED = TagKey.SERVE_AUTO_NUM_REPLICAS_USED + CUSTOM_REQUEST_ROUTER_USED = TagKey.SERVE_CUSTOM_REQUEST_ROUTER_USED + NUM_REPLICAS_VIA_API_CALL_UPDATED = TagKey.SERVE_NUM_REPLICAS_VIA_API_CALL_UPDATED + NUM_REPLICAS_USING_ASYNCHRONOUS_INFERENCE = ( + TagKey.SERVE_NUM_REPLICAS_USING_ASYNCHRONOUS_INFERENCE + ) + CUSTOM_AUTOSCALING_POLICY_USED = TagKey.SERVE_CUSTOM_AUTOSCALING_POLICY_USED + + def record(self, value: str): + """Record telemetry value.""" + record_extra_usage_tag(self.value, value) + + def get_value_from_report(self, report: Dict) -> Optional[str]: + """Returns `None` if the tag isn't in the report.""" + if "extra_usage_tags" not in report: + return None + + return report["extra_usage_tags"].get(TagKey.Name(self.value).lower(), None) diff --git a/lib/python3.12/site-packages/ray/serve/_private/utils.py b/lib/python3.12/site-packages/ray/serve/_private/utils.py new file mode 100644 index 0000000000000000000000000000000000000000..065f6a8a301dbfa84cc621cc0cfd91b02716bb44 --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/_private/utils.py @@ -0,0 +1,726 @@ +import asyncio +import collections +import copy +import importlib +import inspect +import logging +import random +import re +import time +import uuid +from abc import ABC, abstractmethod +from decimal import ROUND_HALF_UP, Decimal +from enum import Enum +from functools import wraps +from typing import Any, Callable, Dict, List, Optional, TypeVar, Union + +import requests + +import ray +import ray.util.serialization_addons +from ray._common.constants import HEAD_NODE_RESOURCE_NAME +from ray._common.utils import get_random_alphanumeric_string, import_attr +from ray._private.worker import LOCAL_MODE, SCRIPT_MODE +from ray._raylet import MessagePackSerializer +from ray.actor import ActorHandle +from ray.serve._private.common import RequestMetadata, ServeComponentType +from ray.serve._private.constants import HTTP_PROXY_TIMEOUT, SERVE_LOGGER_NAME +from ray.types import ObjectRef +from ray.util.serialization import StandaloneSerializationContext + +try: + import pandas as pd +except ImportError: + pd = None + +try: + import numpy as np +except ImportError: + np = None + +FILE_NAME_REGEX = r"[^\x20-\x7E]|[<>:\"/\\|?*]" + +MESSAGE_PACK_OFFSET = 9 + + +def validate_ssl_config( + ssl_certfile: Optional[str], ssl_keyfile: Optional[str] +) -> None: + """Validate SSL configuration for HTTPS support. + + Args: + ssl_certfile: Path to SSL certificate file + ssl_keyfile: Path to SSL private key file + + Raises: + ValueError: If only one of ssl_certfile or ssl_keyfile is provided + """ + if (ssl_certfile and not ssl_keyfile) or (ssl_keyfile and not ssl_certfile): + raise ValueError( + "Both ssl_keyfile and ssl_certfile must be provided together " + "to enable HTTPS." + ) + + +GENERATOR_COMPOSITION_NOT_SUPPORTED_ERROR = RuntimeError( + "Streaming deployment handle results cannot be passed to " + "downstream handle calls. If you have a use case requiring " + "this feature, please file a feature request on GitHub." +) + + +# Use a global singleton enum to emulate default options. We cannot use None +# for those option because None is a valid new value. +class DEFAULT(Enum): + VALUE = 1 + + +class DeploymentOptionUpdateType(str, Enum): + # Nothing needs to be done other than setting the target state. + LightWeight = "LightWeight" + # Each DeploymentReplica instance (tracked in DeploymentState) uses certain options + # from the deployment config. These values need to be updated in DeploymentReplica. + NeedsReconfigure = "NeedsReconfigure" + # Options that are sent to the replica actor. If changed, reconfigure() on the actor + # needs to be called to update these values. + NeedsActorReconfigure = "NeedsActorReconfigure" + # If changed, restart all replicas. + HeavyWeight = "HeavyWeight" + + +# Type alias: objects that can be DEFAULT.VALUE have type Default[T] +T = TypeVar("T") +Default = Union[DEFAULT, T] + +logger = logging.getLogger(SERVE_LOGGER_NAME) + +# Format for component files +FILE_FMT = "{component_name}_{component_id}{suffix}" + + +class _ServeCustomEncoders: + """Group of custom encoders for common types that's not handled by FastAPI.""" + + @staticmethod + def encode_np_array(obj): + assert isinstance(obj, np.ndarray) + if obj.dtype.kind == "f": # floats + obj = obj.astype(float) + if obj.dtype.kind in {"i", "u"}: # signed and unsigned integers. + obj = obj.astype(int) + return obj.tolist() + + @staticmethod + def encode_np_scaler(obj): + assert isinstance(obj, np.generic) + return obj.item() + + @staticmethod + def encode_exception(obj): + assert isinstance(obj, Exception) + return str(obj) + + @staticmethod + def encode_pandas_dataframe(obj): + assert isinstance(obj, pd.DataFrame) + return obj.to_dict(orient="records") + + +serve_encoders = {Exception: _ServeCustomEncoders.encode_exception} + +if np is not None: + serve_encoders[np.ndarray] = _ServeCustomEncoders.encode_np_array + serve_encoders[np.generic] = _ServeCustomEncoders.encode_np_scaler + +if pd is not None: + serve_encoders[pd.DataFrame] = _ServeCustomEncoders.encode_pandas_dataframe + + +@ray.remote(num_cpus=0) +def block_until_http_ready( + http_endpoint, + backoff_time_s=1, + check_ready=None, + timeout=HTTP_PROXY_TIMEOUT, +): + http_is_ready = False + start_time = time.time() + + while not http_is_ready: + try: + resp = requests.get(http_endpoint) + assert resp.status_code == 200 + if check_ready is None: + http_is_ready = True + else: + http_is_ready = check_ready(resp) + except Exception: + pass + + if 0 < timeout < time.time() - start_time: + raise TimeoutError("HTTP proxy not ready after {} seconds.".format(timeout)) + + time.sleep(backoff_time_s) + + +def get_random_string(length: int = 8): + return get_random_alphanumeric_string(length) + + +def format_actor_name(actor_name, *modifiers): + name = actor_name + for modifier in modifiers: + name += "-{}".format(modifier) + + return name + + +def ensure_serialization_context(): + """Ensure the serialization addons on registered, even when Ray has not + been started.""" + ctx = StandaloneSerializationContext() + ray.util.serialization_addons.apply(ctx) + + +def msgpack_serialize(obj): + ctx = ray._private.worker.global_worker.get_serialization_context() + buffer = ctx.serialize(obj) + serialized = buffer.to_bytes() + return serialized + + +def msgpack_deserialize(data): + # todo: Ray does not provide a msgpack deserialization api. + try: + obj = MessagePackSerializer.loads(data[MESSAGE_PACK_OFFSET:], None) + except Exception: + raise + return obj + + +def merge_dict(dict1, dict2): + if dict1 is None and dict2 is None: + return None + if dict1 is None: + dict1 = dict() + if dict2 is None: + dict2 = dict() + result = dict() + for key in dict1.keys() | dict2.keys(): + result[key] = sum([e.get(key, 0) for e in (dict1, dict2)]) + return result + + +def parse_import_path(import_path: str): + """ + Takes in an import_path of form: + + [subdirectory 1].[subdir 2]...[subdir n].[file name].[attribute name] + + Parses this path and returns the module name (everything before the last + dot) and attribute name (everything after the last dot), such that the + attribute can be imported using "from module_name import attr_name". + """ + + nodes = import_path.split(".") + if len(nodes) < 2: + raise ValueError( + f"Got {import_path} as import path. The import path " + f"should at least specify the file name and " + f"attribute name connected by a dot." + ) + + return ".".join(nodes[:-1]), nodes[-1] + + +def override_runtime_envs_except_env_vars(parent_env: Dict, child_env: Dict) -> Dict: + """Creates a runtime_env dict by merging a parent and child environment. + + This method is not destructive. It leaves the parent and child envs + the same. + + The merge is a shallow update where the child environment inherits the + parent environment's settings. If the child environment specifies any + env settings, those settings take precdence over the parent. + - Note: env_vars are a special case. The child's env_vars are combined + with the parent. + + Args: + parent_env: The environment to inherit settings from. + child_env: The environment with override settings. + + Returns: A new dictionary containing the merged runtime_env settings. + + Raises: + TypeError: If a dictionary is not passed in for parent_env or child_env. + """ + + if not isinstance(parent_env, Dict): + raise TypeError( + f'Got unexpected type "{type(parent_env)}" for parent_env. ' + "parent_env must be a dictionary." + ) + if not isinstance(child_env, Dict): + raise TypeError( + f'Got unexpected type "{type(child_env)}" for child_env. ' + "child_env must be a dictionary." + ) + + defaults = copy.deepcopy(parent_env) + overrides = copy.deepcopy(child_env) + + default_env_vars = defaults.get("env_vars", {}) + override_env_vars = overrides.get("env_vars", {}) + + defaults.update(overrides) + default_env_vars.update(override_env_vars) + + defaults["env_vars"] = default_env_vars + + return defaults + + +class JavaActorHandleProxy: + """Wraps actor handle and translate snake_case to camelCase.""" + + def __init__(self, handle: ActorHandle): + self.handle = handle + self._available_attrs = set(dir(self.handle)) + + def __getattr__(self, key: str): + if key in self._available_attrs: + camel_case_key = key + else: + components = key.split("_") + camel_case_key = components[0] + "".join(x.title() for x in components[1:]) + return getattr(self.handle, camel_case_key) + + +def require_packages(packages: List[str]): + """Decorator making sure function run in specified environments + + Examples: + >>> from ray.serve._private.utils import require_packages + >>> @require_packages(["numpy", "package_a"]) # doctest: +SKIP + ... def func(): # doctest: +SKIP + ... import numpy as np # doctest: +SKIP + ... ... # doctest: +SKIP + >>> func() # doctest: +SKIP + ImportError: func requires ["numpy", "package_a"] but + ["package_a"] are not available, please pip install them. + """ + + def decorator(func): + def check_import_once(): + if not hasattr(func, "_require_packages_checked"): + missing_packages = [] + for package in packages: + try: + importlib.import_module(package) + except ModuleNotFoundError: + missing_packages.append(package) + if len(missing_packages) > 0: + raise ImportError( + f"{func} requires packages {packages} to run but " + f"{missing_packages} are missing. Please " + "`pip install` them or add them to " + "`runtime_env`." + ) + func._require_packages_checked = True + + if inspect.iscoroutinefunction(func): + + @wraps(func) + async def wrapped(*args, **kwargs): + check_import_once() + return await func(*args, **kwargs) + + elif inspect.isroutine(func): + + @wraps(func) + def wrapped(*args, **kwargs): + check_import_once() + return func(*args, **kwargs) + + else: + raise ValueError("Decorator expect callable functions.") + + return wrapped + + return decorator + + +def in_interactive_shell(): + # Taken from: + # https://stackoverflow.com/questions/15411967/how-can-i-check-if-code-is-executed-in-the-ipython-notebook + import __main__ as main + + return not hasattr(main, "__file__") + + +def snake_to_camel_case(snake_str: str) -> str: + """Convert a snake case string to camel case.""" + + words = snake_str.strip("_").split("_") + return words[0] + "".join(word[:1].upper() + word[1:] for word in words[1:]) + + +def check_obj_ref_ready_nowait(obj_ref: ObjectRef) -> bool: + """Check if ray object reference is ready without waiting for it.""" + finished, _ = ray.wait([obj_ref], timeout=0) + return len(finished) == 1 + + +def extract_self_if_method_call(args: List[Any], func: Callable) -> Optional[object]: + """Check if this is a method rather than a function. + + Does this by checking to see if `func` is the attribute of the first + (`self`) argument under `func.__name__`. Unfortunately, this is the most + robust solution to this I was able to find. It would also be preferable + to do this check when the decorator runs, rather than when the method is. + + Returns the `self` object if it's a method call, else None. + + Arguments: + args: arguments to the function/method call. + func: the unbound function that was called. + """ + if len(args) > 0: + method = getattr(args[0], func.__name__, False) + if method: + wrapped = getattr(method, "__wrapped__", False) + if wrapped and wrapped == func: + return args[0] + + return None + + +def call_function_from_import_path(import_path: str) -> Any: + """Call the function given import path. + + Args: + import_path: The import path of the function to call. + Raises: + ValueError: If the import path is invalid. + TypeError: If the import path is not callable. + RuntimeError: if the function raise exeception during execution. + Returns: + The result of the function call. + """ + try: + callback_func = import_attr(import_path) + except Exception as e: + raise ValueError(f"The import path {import_path} cannot be imported: {e}") + + if not callable(callback_func): + raise TypeError(f"The import path {import_path} is not callable.") + + try: + return callback_func() + except Exception as e: + raise RuntimeError(f"The function {import_path} raised an exception: {e}") + + +def get_head_node_id() -> str: + """Get the head node id. + + Iterate through all nodes in the ray cluster and return the node id of the first + alive node with head node resource. + """ + head_node_id = None + for node in ray.nodes(): + if HEAD_NODE_RESOURCE_NAME in node["Resources"] and node["Alive"]: + head_node_id = node["NodeID"] + break + assert head_node_id is not None, "Cannot find alive head node." + + return head_node_id + + +def calculate_remaining_timeout( + *, + timeout_s: Optional[float], + start_time_s: float, + curr_time_s: float, +) -> Optional[float]: + """Get the timeout remaining given an overall timeout, start time, and curr time. + + If the timeout passed in was `None` or negative, will always return that timeout + directly. + + If the timeout is >= 0, the returned remaining timeout always be >= 0. + """ + if timeout_s is None or timeout_s < 0: + return timeout_s + + time_since_start_s = curr_time_s - start_time_s + return max(0, timeout_s - time_since_start_s) + + +def get_all_live_placement_group_names() -> List[str]: + """Fetch and parse the Ray placement group table for live placement group names. + + Placement groups are filtered based on their `scheduling_state`; any placement + group not in the "REMOVED" state is considered live. + """ + placement_group_table = ray.util.placement_group_table() + + live_pg_names = [] + for entry in placement_group_table.values(): + pg_name = entry.get("name", "") + if ( + pg_name + and entry.get("stats", {}).get("scheduling_state", "UNKNOWN") != "REMOVED" + ): + live_pg_names.append(pg_name) + + return live_pg_names + + +def get_current_actor_id() -> str: + """Gets the ID of the calling actor. + + If this is called in a driver, returns "DRIVER." + + If otherwise called outside of an actor, returns an empty string. + + This function hangs when GCS is down due to the `ray.get_runtime_context()` + call. + """ + + worker_mode = ray.get_runtime_context().worker.mode + if worker_mode in {SCRIPT_MODE, LOCAL_MODE}: + return "DRIVER" + else: + try: + actor_id = ray.get_runtime_context().get_actor_id() + if actor_id is None: + return "" + else: + return actor_id + except Exception: + return "" + + +def is_running_in_asyncio_loop() -> bool: + try: + asyncio.get_running_loop() + return True + except RuntimeError: + return False + + +class TimerBase(ABC): + @abstractmethod + def time(self) -> float: + """Return the current time.""" + raise NotImplementedError + + +class Timer(TimerBase): + def time(self) -> float: + return time.time() + + +def get_capacity_adjusted_num_replicas( + num_replicas: int, target_capacity: Optional[float] +) -> int: + """Return the `num_replicas` adjusted by the `target_capacity`. + + The output will only ever be 0 if `target_capacity` is 0 or `num_replicas` is + 0 (to support autoscaling deployments using scale-to-zero). + + Rather than using the default `round` behavior in Python, which rounds half to + even, uses the `decimal` module to round half up (standard rounding behavior). + """ + if target_capacity is None or target_capacity == 100: + return num_replicas + + if target_capacity == 0 or num_replicas == 0: + return 0 + + adjusted_num_replicas = Decimal(num_replicas * target_capacity) / Decimal(100.0) + rounded_adjusted_num_replicas = adjusted_num_replicas.to_integral_value( + rounding=ROUND_HALF_UP + ) + return max(1, int(rounded_adjusted_num_replicas)) + + +def generate_request_id() -> str: + # NOTE(edoakes): we use random.getrandbits because it reduces CPU overhead + # significantly. This is less cryptographically secure but should be ok for + # request ID generation. + # See https://bugs.python.org/issue45556 for discussion. + return str(uuid.UUID(int=random.getrandbits(128), version=4)) + + +def inside_ray_client_context() -> bool: + return ray.util.client.ray.is_connected() + + +def get_component_file_name( + component_name: str, + component_id: str, + component_type: Optional[ServeComponentType], + suffix: str = "", +) -> str: + """Get the component's file name. Replaces special characters with underscores.""" + component_name = re.sub(FILE_NAME_REGEX, "_", component_name) + + # For DEPLOYMENT component type, we want to log the deployment name + # instead of adding the component type to the component name. + component_log_file_name = component_name + if component_type is not None: + component_log_file_name = f"{component_type.value}_{component_name}" + if component_type != ServeComponentType.REPLICA: + component_name = f"{component_type}_{component_name}" + file_name = FILE_FMT.format( + component_name=component_log_file_name, + component_id=component_id, + suffix=suffix, + ) + return file_name + + +def validate_route_prefix(route_prefix: Union[DEFAULT, None, str]): + if route_prefix is DEFAULT.VALUE or route_prefix is None: + return + + if not route_prefix.startswith("/"): + raise ValueError( + f"Invalid route_prefix '{route_prefix}', " + "must start with a forward slash ('/')." + ) + + if route_prefix != "/" and route_prefix.endswith("/"): + raise ValueError( + f"Invalid route_prefix '{route_prefix}', " + "may not end with a trailing '/'." + ) + + if "{" in route_prefix or "}" in route_prefix: + raise ValueError( + f"Invalid route_prefix '{route_prefix}', may not contain wildcards." + ) + + +async def resolve_deployment_response(obj: Any, request_metadata: RequestMetadata): + """Resolve `DeploymentResponse` objects to underlying object references. + + This enables composition without explicitly calling `_to_object_ref`. + """ + from ray.serve.handle import DeploymentResponse, DeploymentResponseGenerator + + if isinstance(obj, DeploymentResponseGenerator): + raise GENERATOR_COMPOSITION_NOT_SUPPORTED_ERROR + elif isinstance(obj, DeploymentResponse): + # Launch async task to convert DeploymentResponse to an object ref + return asyncio.create_task(obj._to_object_ref()) + + +def wait_for_interrupt() -> None: + try: + while True: + # Block, letting Ray print logs to the terminal. + time.sleep(10) + except KeyboardInterrupt: + logger.warning("Got KeyboardInterrupt, exiting...") + # We need to re-raise KeyboardInterrupt, so serve components can be shutdown + # from the main script. + raise + + +def is_grpc_enabled(grpc_config) -> bool: + return grpc_config.port > 0 and len(grpc_config.grpc_servicer_functions) > 0 + + +class Semaphore: + """Based on asyncio.Semaphore. + + This is a semaphore that can be used to limit the number of concurrent requests. + Its maximum value is dynamic and is determined by the `get_value_fn` function. + """ + + def __init__(self, get_value_fn: Callable[[], int]): + self._waiters = None + self._value = 0 + self._get_value_fn = get_value_fn + + def __repr__(self): + res = super().__repr__() + extra = "locked" if self.locked() else f"unlocked, value:{self._value}" + if self._waiters: + extra = f"{extra}, waiters:{len(self._waiters)}" + return f"<{res[1:-1]} [{extra}]>" + + async def __aenter__(self): + await self.acquire() + # We have no use for the "as ..." clause in the with + # statement for locks. + return None + + async def __aexit__(self, exc_type, exc, tb): + self.release() + + def get_max_value(self): + return self._get_value_fn() + + def locked(self): + """Returns True if semaphore cannot be acquired immediately.""" + return self._value >= self.get_max_value() or ( + any(not w.cancelled() for w in (self._waiters or ())) + ) + + async def acquire(self): + """Acquire a semaphore. + If the internal counter is larger than zero on entry, + decrement it by one and return True immediately. If it is + zero on entry, block, waiting until some other coroutine has + called release() to make it larger than 0, and then return + True. + """ + if not self.locked(): + self._value += 1 + return True + + if self._waiters is None: + self._waiters = collections.deque() + fut = asyncio.Future() + self._waiters.append(fut) + + # Finally block should be called before the CancelledError + # handling as we don't want CancelledError to call + # _wake_up_first() and attempt to wake up itself. + try: + try: + await fut + finally: + self._waiters.remove(fut) + except asyncio.CancelledError: + if not fut.cancelled(): + self._value -= 1 + self._wake_up_next() + raise + + if self._value < self.get_max_value(): + self._wake_up_next() + return True + + def release(self): + """Release a semaphore, incrementing the internal counter by one. + When it was zero on entry and another coroutine is waiting for it to + become larger than zero again, wake up that coroutine. + """ + self._value -= 1 + self._wake_up_next() + + def _wake_up_next(self): + """Wake up the first waiter that isn't done.""" + if not self._waiters: + return + + for fut in self._waiters: + if not fut.done(): + self._value += 1 + fut.set_result(True) + return diff --git a/lib/python3.12/site-packages/ray/serve/_private/version.py b/lib/python3.12/site-packages/ray/serve/_private/version.py new file mode 100644 index 0000000000000000000000000000000000000000..1c064a9a9dc7ad416fc092b0c1b12dafd537c9ec --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/_private/version.py @@ -0,0 +1,217 @@ +import json +import logging +from copy import deepcopy +from typing import Any, Dict, List, Optional +from zlib import crc32 + +from ray._common.pydantic_compat import BaseModel +from ray.serve._private.config import DeploymentConfig +from ray.serve._private.utils import DeploymentOptionUpdateType, get_random_string +from ray.serve.config import AutoscalingConfig +from ray.serve.generated.serve_pb2 import DeploymentVersion as DeploymentVersionProto + +logger = logging.getLogger("ray.serve") + + +class DeploymentVersion: + def __init__( + self, + code_version: Optional[str], + deployment_config: DeploymentConfig, + ray_actor_options: Optional[Dict], + placement_group_bundles: Optional[List[Dict[str, float]]] = None, + placement_group_strategy: Optional[str] = None, + max_replicas_per_node: Optional[int] = None, + route_prefix: Optional[str] = None, + ): + if code_version is not None and not isinstance(code_version, str): + raise TypeError(f"code_version must be str, got {type(code_version)}.") + if code_version is None: + self.code_version = get_random_string() + else: + self.code_version = code_version + + # Options for this field may be mutated over time, so any logic that uses this + # should access this field directly. + self.deployment_config = deployment_config + self.ray_actor_options = ray_actor_options + self.placement_group_bundles = placement_group_bundles + self.placement_group_strategy = placement_group_strategy + self.max_replicas_per_node = max_replicas_per_node + self.route_prefix = route_prefix + self.compute_hashes() + + @classmethod + def from_deployment_version( + cls, deployment_version, deployment_config, route_prefix: Optional[str] = None + ): + version_copy = deepcopy(deployment_version) + version_copy.deployment_config = deployment_config + version_copy.route_prefix = route_prefix + version_copy.compute_hashes() + return version_copy + + def __hash__(self) -> int: + return self._hash + + def __eq__(self, other: Any) -> bool: + if not isinstance(other, DeploymentVersion): + return False + return self._hash == other._hash + + def requires_actor_restart(self, new_version): + """Determines whether the new version requires actors of the current version to + be restarted. + """ + return ( + self.code_version != new_version.code_version + or self.ray_actor_options_hash != new_version.ray_actor_options_hash + or self.placement_group_options_hash + != new_version.placement_group_options_hash + or self.max_replicas_per_node != new_version.max_replicas_per_node + ) + + def requires_actor_reconfigure(self, new_version): + """Determines whether the new version requires calling reconfigure() on the + replica actor. + """ + return self.reconfigure_actor_hash != new_version.reconfigure_actor_hash + + def requires_long_poll_broadcast(self, new_version): + """Determines whether lightweightly updating an existing replica to the new + version requires broadcasting through long poll that the running replicas has + changed. + """ + return ( + self.deployment_config.max_ongoing_requests + != new_version.deployment_config.max_ongoing_requests + ) + + def compute_hashes(self): + # If these change, the controller will rolling upgrade existing replicas. + serialized_ray_actor_options = _serialize(self.ray_actor_options or {}) + self.ray_actor_options_hash = crc32(serialized_ray_actor_options) + combined_placement_group_options = {} + if self.placement_group_bundles is not None: + combined_placement_group_options["bundles"] = self.placement_group_bundles + if self.placement_group_strategy is not None: + combined_placement_group_options["strategy"] = self.placement_group_strategy + serialized_placement_group_options = _serialize( + combined_placement_group_options + ) + self.placement_group_options_hash = crc32(serialized_placement_group_options) + # Include app-level route prefix in the version hashes so changing + # it triggers an in-place reconfigure of running replicas. + serialized_route_prefix = _serialize(self.route_prefix) + + # If this changes, DeploymentReplica.reconfigure() will call reconfigure on the + # actual replica actor + self.reconfigure_actor_hash = crc32( + serialized_route_prefix + + self._get_serialized_options( + [DeploymentOptionUpdateType.NeedsActorReconfigure] + ) + ) + + # Used by __eq__ in deployment state to either reconfigure the replicas or + # stop and restart them + self._hash = crc32( + self.code_version.encode("utf-8") + + serialized_ray_actor_options + + serialized_placement_group_options + + str(self.max_replicas_per_node).encode("utf-8") + + serialized_route_prefix + + self._get_serialized_options( + [ + DeploymentOptionUpdateType.NeedsReconfigure, + DeploymentOptionUpdateType.NeedsActorReconfigure, + ] + ) + ) + + def to_proto(self) -> bytes: + # TODO(simon): enable cross language user config + return DeploymentVersionProto( + code_version=self.code_version, + deployment_config=self.deployment_config.to_proto(), + ray_actor_options=json.dumps(self.ray_actor_options), + placement_group_bundles=json.dumps(self.placement_group_bundles) + if self.placement_group_bundles is not None + else "", + placement_group_strategy=self.placement_group_strategy + if self.placement_group_strategy is not None + else "", + max_replicas_per_node=self.max_replicas_per_node + if self.max_replicas_per_node is not None + else 0, + ) + + @classmethod + def from_proto(cls, proto: DeploymentVersionProto): + return DeploymentVersion( + proto.code_version, + DeploymentConfig.from_proto(proto.deployment_config), + json.loads(proto.ray_actor_options), + placement_group_bundles=( + json.loads(proto.placement_group_bundles) + if proto.placement_group_bundles + else None + ), + placement_group_version=( + proto.placement_group_version if proto.placement_group_version else None + ), + max_replicas_per_node=( + proto.max_replicas_per_node if proto.max_replicas_per_node else None + ), + ) + + def _get_serialized_options( + self, update_types: List[DeploymentOptionUpdateType] + ) -> bytes: + """Returns a serialized dictionary containing fields of a deployment config that + should prompt a deployment version update. + """ + reconfigure_dict = {} + # TODO(aguo): Once we only support pydantic 2, we can remove this if check. + # In pydantic 2.0, `__fields__` has been renamed to `model_fields`. + fields = ( + self.deployment_config.model_fields + if hasattr(self.deployment_config, "model_fields") + else self.deployment_config.__fields__ + ) + for option_name, field in fields.items(): + option_weight = field.field_info.extra.get("update_type") + if option_weight in update_types: + reconfigure_dict[option_name] = getattr( + self.deployment_config, option_name + ) + # If autoscaling config was changed, only broadcast to + # replicas if metrics_interval_s or look_back_period_s + # was changed, because the rest of the fields are only + # used in deployment state manager + if isinstance(reconfigure_dict[option_name], AutoscalingConfig): + reconfigure_dict[option_name] = reconfigure_dict[option_name].dict( + include={"metrics_interval_s", "look_back_period_s"} + ) + elif isinstance(reconfigure_dict[option_name], BaseModel): + reconfigure_dict[option_name] = reconfigure_dict[option_name].dict() + + # Can't serialize bytes. The request router class is already + # included in the serialized config as request_router_class. + if "request_router_config" in reconfigure_dict: + reconfigure_dict["request_router_config"].pop( + "_serialized_request_router_cls", None + ) + + if ( + isinstance(self.deployment_config.user_config, bytes) + and "user_config" in reconfigure_dict + ): + del reconfigure_dict["user_config"] + return self.deployment_config.user_config + _serialize(reconfigure_dict) + + return _serialize(reconfigure_dict) + + +def _serialize(json_object): + return str.encode(json.dumps(json_object, sort_keys=True)) diff --git a/lib/python3.12/site-packages/ray/serve/api.py b/lib/python3.12/site-packages/ray/serve/api.py new file mode 100644 index 0000000000000000000000000000000000000000..d18bbd3d0e695e72cbbc37a11f34a6e660e1c665 --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/api.py @@ -0,0 +1,1079 @@ +import collections +import inspect +import logging +from functools import wraps +from typing import Any, Callable, Dict, List, Optional, Sequence, Type, Union + +from attr import dataclass +from fastapi import APIRouter, FastAPI +from starlette.types import ASGIApp + +import ray +from ray import cloudpickle +from ray._common.serialization import pickle_dumps +from ray.serve._private.build_app import build_app +from ray.serve._private.config import ( + DeploymentConfig, + ReplicaConfig, + handle_num_replicas_auto, + prepare_imperative_http_options, +) +from ray.serve._private.constants import ( + RAY_SERVE_FORCE_LOCAL_TESTING_MODE, + SERVE_DEFAULT_APP_NAME, + SERVE_LOGGER_NAME, +) +from ray.serve._private.http_util import ( + ASGIAppReplicaWrapper, + make_fastapi_class_based_view, +) +from ray.serve._private.local_testing_mode import make_local_deployment_handle +from ray.serve._private.logging_utils import configure_component_logger +from ray.serve._private.usage import ServeUsageTag +from ray.serve._private.utils import ( + DEFAULT, + Default, + ensure_serialization_context, + extract_self_if_method_call, + validate_route_prefix, + wait_for_interrupt, +) +from ray.serve.config import ( + AutoscalingConfig, + HTTPOptions, + ProxyLocation, + RequestRouterConfig, + gRPCOptions, +) +from ray.serve.context import ( + ReplicaContext, + _get_global_client, + _get_internal_replica_context, + _set_global_client, +) +from ray.serve.deployment import Application, Deployment +from ray.serve.exceptions import RayServeException +from ray.serve.handle import DeploymentHandle +from ray.serve.multiplex import _ModelMultiplexWrapper +from ray.serve.schema import LoggingConfig, ServeInstanceDetails, ServeStatus +from ray.util.annotations import DeveloperAPI, PublicAPI + +from ray.serve._private import api as _private_api # isort:skip + + +logger = logging.getLogger(SERVE_LOGGER_NAME) + + +@PublicAPI(stability="stable") +def start( + proxy_location: Union[None, str, ProxyLocation] = None, + http_options: Union[None, dict, HTTPOptions] = None, + grpc_options: Union[None, dict, gRPCOptions] = None, + logging_config: Union[None, dict, LoggingConfig] = None, + **kwargs, +): + """Start Serve on the cluster. + + Used to set cluster-scoped configurations such as HTTP options. In most cases, this + does not need to be called manually and Serve will be started when an application is + first deployed to the cluster. + + These cluster-scoped options cannot be updated dynamically. To update them, start a + new cluster or shut down Serve on the cluster and start it again. + + These options can also be set in the config file deployed via REST API. + + Args: + proxy_location: Where to run proxies that handle ingress traffic to the + cluster (defaults to every node in the cluster with at least one replica on + it). See `ProxyLocation` for supported options. + http_options: HTTP config options for the proxies. These can be passed as an + unstructured dictionary or the structured `HTTPOptions` class. See + `HTTPOptions` for supported options. + grpc_options: [EXPERIMENTAL] gRPC config options for the proxies. These can + be passed as an unstructured dictionary or the structured `gRPCOptions` + class See `gRPCOptions` for supported options. + logging_config: logging config options for the serve component ( + controller & proxy). + """ + http_options = prepare_imperative_http_options(proxy_location, http_options) + _private_api.serve_start( + http_options=http_options, + grpc_options=grpc_options, + global_logging_config=logging_config, + **kwargs, + ) + + +@PublicAPI(stability="stable") +def shutdown(): + """Completely shut down Serve on the cluster. + + Deletes all applications and shuts down Serve system actors. + """ + + try: + client = _get_global_client() + except RayServeException: + logger.info( + "Nothing to shut down. There's no Serve application " + "running on this Ray cluster." + ) + return + + client.shutdown() + _set_global_client(None) + + +@PublicAPI(stability="alpha") +async def shutdown_async(): + """Completely shut down Serve on the cluster asynchronously. + + Deletes all applications and shuts down Serve system actors. + """ + + try: + client = _get_global_client() + except RayServeException: + logger.info( + "Nothing to shut down. There's no Serve application " + "running on this Ray cluster." + ) + return + + await client.shutdown_async() + _set_global_client(None) + + +@DeveloperAPI +def get_replica_context() -> ReplicaContext: + """Returns the deployment and replica tag from within a replica at runtime. + + A replica tag uniquely identifies a single replica for a Ray Serve + deployment. + + Raises: + RayServeException: if not called from within a Ray Serve deployment. + + Example: + + .. code-block:: python + + from ray import serve + @serve.deployment + class MyDeployment: + def __init__(self): + # Prints "MyDeployment" + print(serve.get_replica_context().deployment) + + """ + internal_replica_context = _get_internal_replica_context() + if internal_replica_context is None: + raise RayServeException( + "`serve.get_replica_context()` " + "may only be called from within a " + "Ray Serve deployment." + ) + return internal_replica_context + + +@PublicAPI(stability="stable") +def ingress(app: Union[ASGIApp, Callable]) -> Callable: + """Wrap a deployment class with an ASGI application for HTTP request parsing. + There are a few different ways to use this functionality. + + Example: + + FastAPI app routes are defined inside the deployment class. + + .. code-block:: python + + from ray import serve + from fastapi import FastAPI + + app = FastAPI() + + @serve.deployment + @serve.ingress(app) + class MyFastAPIDeployment: + @app.get("/hi") + def say_hi(self) -> str: + return "Hello world!" + + app = MyFastAPIDeployment.bind() + + You can also use a standalone FastAPI app without registering + routes inside the deployment. + + .. code-block:: python + + from ray import serve + from fastapi import FastAPI + + app = FastAPI() + + @app.get("/hi") + def say_hi(): + return "Hello world!" + + deployment = serve.deployment(serve.ingress(app)()) + app = deployment.bind() + + You can also pass in a builder function that returns an ASGI app. + The builder function is evaluated when the deployment is initialized on + replicas. This example shows how to use a sub-deployment inside the routes + defined outside the deployment class. + + .. code-block:: python + + from ray import serve + + @serve.deployment + class SubDeployment: + def __call__(self): + return "Hello world!" + + def build_asgi_app(): + from fastapi import FastAPI + + app = FastAPI() + + def get_sub_deployment_handle(): + return serve.get_deployment_handle(SubDeployment.name, app_name="my_app") + + @app.get("/hi") + async def say_hi(handle: Depends(get_sub_deployment_handle)): + return await handle.remote() + + return app + + deployment = serve.deployment(serve.ingress(build_asgi_app)()) + app = deployment.bind(SubDeployment.bind(), name="my_app", route_prefix="/") + + Args: + app: the FastAPI app to wrap this class with. + Can be any ASGI-compatible callable. + You can also pass in a builder function that returns an ASGI app. + """ + + def decorator(cls: Optional[Type[Any]] = None) -> Callable: + if cls is None: + + class ASGIIngressDeployment: + def __init__(self, *args, **kwargs): + self.args = args + self.kwargs = kwargs + + cls = ASGIIngressDeployment + + if not inspect.isclass(cls): + raise ValueError("@serve.ingress must be used with a class.") + if issubclass(cls, collections.abc.Callable): + raise ValueError( + "Classes passed to @serve.ingress may not have __call__ method." + ) + + # Sometimes there are decorators on the methods. We want to fix + # the fast api routes here. + if isinstance(app, (FastAPI, APIRouter)): + make_fastapi_class_based_view(app, cls) + + frozen_app_or_func: Union[ASGIApp, Callable] = None + + if inspect.isfunction(app): + frozen_app_or_func = app + else: + # Free the state of the app so subsequent modification won't affect + # this ingress deployment. We don't use copy.copy here to avoid + # recursion issue. + ensure_serialization_context() + frozen_app_or_func = cloudpickle.loads( + pickle_dumps(app, error_msg="Failed to serialize the ASGI app.") + ) + + class ASGIIngressWrapper(cls, ASGIAppReplicaWrapper): + def __init__(self, *args, **kwargs): + # Call user-defined constructor. + cls.__init__(self, *args, **kwargs) + + ServeUsageTag.FASTAPI_USED.record("1") + ASGIAppReplicaWrapper.__init__(self, frozen_app_or_func) + + async def __del__(self): + await ASGIAppReplicaWrapper.__del__(self) + + # Call user-defined destructor if defined. + if hasattr(cls, "__del__"): + if inspect.iscoroutinefunction(cls.__del__): + await cls.__del__(self) + else: + cls.__del__(self) + + ASGIIngressWrapper.__name__ = cls.__name__ + + return ASGIIngressWrapper + + return decorator + + +@PublicAPI(stability="stable") +def deployment( + _func_or_class: Optional[Callable] = None, + name: Default[str] = DEFAULT.VALUE, + version: Default[str] = DEFAULT.VALUE, + num_replicas: Default[Optional[Union[int, str]]] = DEFAULT.VALUE, + route_prefix: Default[Union[str, None]] = DEFAULT.VALUE, + ray_actor_options: Default[Dict] = DEFAULT.VALUE, + placement_group_bundles: Default[List[Dict[str, float]]] = DEFAULT.VALUE, + placement_group_strategy: Default[str] = DEFAULT.VALUE, + max_replicas_per_node: Default[int] = DEFAULT.VALUE, + user_config: Default[Optional[Any]] = DEFAULT.VALUE, + max_ongoing_requests: Default[int] = DEFAULT.VALUE, + max_queued_requests: Default[int] = DEFAULT.VALUE, + autoscaling_config: Default[Union[Dict, AutoscalingConfig, None]] = DEFAULT.VALUE, + graceful_shutdown_wait_loop_s: Default[float] = DEFAULT.VALUE, + graceful_shutdown_timeout_s: Default[float] = DEFAULT.VALUE, + health_check_period_s: Default[float] = DEFAULT.VALUE, + health_check_timeout_s: Default[float] = DEFAULT.VALUE, + logging_config: Default[Union[Dict, LoggingConfig, None]] = DEFAULT.VALUE, + request_router_config: Default[ + Union[Dict, RequestRouterConfig, None] + ] = DEFAULT.VALUE, + max_constructor_retry_count: Default[int] = DEFAULT.VALUE, +) -> Callable[[Callable], Deployment]: + """Decorator that converts a Python class to a `Deployment`. + + Example: + + .. code-block:: python + + from ray import serve + + @serve.deployment(num_replicas=2) + class MyDeployment: + pass + + app = MyDeployment.bind() + + Args: + _func_or_class: The class or function to be decorated. + name: Name uniquely identifying this deployment within the application. + If not provided, the name of the class or function is used. + version: Version of the deployment. Deprecated. + num_replicas: Number of replicas to run that handle requests to + this deployment. Defaults to 1. + route_prefix: Route prefix for HTTP requests. Defaults to '/'. Deprecated. + ray_actor_options: Options to pass to the Ray Actor decorator, such as + resource requirements. Valid options are: `accelerator_type`, `memory`, + `num_cpus`, `num_gpus`, `resources`, and `runtime_env`. + placement_group_bundles: Defines a set of placement group bundles to be + scheduled *for each replica* of this deployment. The replica actor will + be scheduled in the first bundle provided, so the resources specified in + `ray_actor_options` must be a subset of the first bundle's resources. All + actors and tasks created by the replica actor will be scheduled in the + placement group by default (`placement_group_capture_child_tasks` is set + to True). + This cannot be set together with max_replicas_per_node. + placement_group_strategy: Strategy to use for the replica placement group + specified via `placement_group_bundles`. Defaults to `PACK`. + max_replicas_per_node: The max number of replicas of this deployment that can + run on a single node. Valid values are None (default, no limit) + or an integer in the range of [1, 100]. + This cannot be set together with placement_group_bundles. + user_config: Config to pass to the reconfigure method of the deployment. This + can be updated dynamically without restarting the replicas of the + deployment. The user_config must be fully JSON-serializable. + max_ongoing_requests: Maximum number of requests that are sent to a + replica of this deployment without receiving a response. Defaults to 5. + max_queued_requests: [EXPERIMENTAL] Maximum number of requests to this + deployment that will be queued at each *caller* (proxy or DeploymentHandle). + Once this limit is reached, subsequent requests will raise a + BackPressureError (for handles) or return an HTTP 503 status code (for HTTP + requests). Defaults to -1 (no limit). + autoscaling_config: Parameters to configure autoscaling behavior. If this + is set, `num_replicas` should be "auto" or not set. + graceful_shutdown_wait_loop_s: Duration that replicas wait until there is + no more work to be done before shutting down. Defaults to 2s. + graceful_shutdown_timeout_s: Duration to wait for a replica to gracefully + shut down before being forcefully killed. Defaults to 20s. + health_check_period_s: Duration between health check calls for the replica. + Defaults to 10s. The health check is by default a no-op Actor call to the + replica, but you can define your own health check using the "check_health" + method in your deployment that raises an exception when unhealthy. + health_check_timeout_s: Duration in seconds, that replicas wait for a health + check method to return before considering it as failed. Defaults to 30s. + logging_config: Logging config options for the deployment. If provided, + the config will be used to set up the Serve logger on the deployment. + request_router_config: Config for the request router used for this deployment. + max_constructor_retry_count: Maximum number of times to retry the deployment + constructor. Defaults to 20. + Returns: + `Deployment` + """ + if route_prefix is not DEFAULT.VALUE: + raise ValueError( + "`route_prefix` can no longer be specified at the deployment level. " + "Pass it to `serve.run` or in the application config instead." + ) + + if max_ongoing_requests is None: + raise ValueError("`max_ongoing_requests` must be non-null, got None.") + + if num_replicas == "auto": + num_replicas = None + max_ongoing_requests, autoscaling_config = handle_num_replicas_auto( + max_ongoing_requests, autoscaling_config + ) + + ServeUsageTag.AUTO_NUM_REPLICAS_USED.record("1") + + # NOTE: The user_configured_option_names should be the first thing that's + # defined in this function. It depends on the locals() dictionary storing + # only the function args/kwargs. + # Create list of all user-configured options from keyword args + user_configured_option_names = [ + option + for option, value in locals().items() + if option != "_func_or_class" and value is not DEFAULT.VALUE + ] + + # Num of replicas should not be 0. + # TODO(Sihan) separate num_replicas attribute from internal and api + if num_replicas == 0: + raise ValueError("num_replicas is expected to larger than 0") + + if num_replicas not in [DEFAULT.VALUE, None, "auto"] and autoscaling_config not in [ + DEFAULT.VALUE, + None, + ]: + raise ValueError( + "Manually setting num_replicas is not allowed when " + "autoscaling_config is provided." + ) + + if version is not DEFAULT.VALUE: + logger.warning( + "DeprecationWarning: `version` in `@serve.deployment` has been deprecated. " + "Explicitly specifying version will raise an error in the future!" + ) + + if isinstance(logging_config, LoggingConfig): + logging_config = logging_config.dict() + + deployment_config = DeploymentConfig.from_default( + num_replicas=num_replicas if num_replicas is not None else 1, + user_config=user_config, + max_ongoing_requests=max_ongoing_requests, + max_queued_requests=max_queued_requests, + autoscaling_config=autoscaling_config, + graceful_shutdown_wait_loop_s=graceful_shutdown_wait_loop_s, + graceful_shutdown_timeout_s=graceful_shutdown_timeout_s, + health_check_period_s=health_check_period_s, + health_check_timeout_s=health_check_timeout_s, + logging_config=logging_config, + request_router_config=request_router_config, + max_constructor_retry_count=max_constructor_retry_count, + ) + deployment_config.user_configured_option_names = set(user_configured_option_names) + + def decorator(_func_or_class): + replica_config = ReplicaConfig.create( + _func_or_class, + init_args=None, + init_kwargs=None, + ray_actor_options=( + ray_actor_options if ray_actor_options is not DEFAULT.VALUE else None + ), + placement_group_bundles=( + placement_group_bundles + if placement_group_bundles is not DEFAULT.VALUE + else None + ), + placement_group_strategy=( + placement_group_strategy + if placement_group_strategy is not DEFAULT.VALUE + else None + ), + max_replicas_per_node=( + max_replicas_per_node + if max_replicas_per_node is not DEFAULT.VALUE + else None + ), + ) + + return Deployment( + name if name is not DEFAULT.VALUE else _func_or_class.__name__, + deployment_config, + replica_config, + version=(version if version is not DEFAULT.VALUE else None), + _internal=True, + ) + + # This handles both parametrized and non-parametrized usage of the + # decorator. See the @serve.batch code for more details. + return decorator(_func_or_class) if callable(_func_or_class) else decorator + + +@DeveloperAPI +@dataclass(frozen=True) +class RunTarget: + """Represents a Serve application to run for `serve.run_many`.""" + + target: Application + name: str = SERVE_DEFAULT_APP_NAME + route_prefix: Optional[str] = "/" + logging_config: Optional[Union[Dict, LoggingConfig]] = None + + +@DeveloperAPI +def _run_many( + targets: Sequence[RunTarget], + wait_for_ingress_deployment_creation: bool = True, + wait_for_applications_running: bool = True, + _local_testing_mode: bool = False, +) -> List[DeploymentHandle]: + """Run many applications and return the handles to their ingress deployments. + + This is only used internally with the _blocking not totally blocking the following + code indefinitely until Ctrl-C'd. + """ + if not targets: + raise ValueError("No applications provided.") + + if RAY_SERVE_FORCE_LOCAL_TESTING_MODE: + if not _local_testing_mode: + logger.info("Overriding local_testing_mode=True from environment variable.") + + _local_testing_mode = True + + built_apps = [] + for t in targets: + if len(t.name) == 0: + raise RayServeException("Application name must a non-empty string.") + + if not isinstance(t.target, Application): + raise TypeError( + "`serve.run` expects an `Application` returned by `Deployment.bind()`." + ) + + validate_route_prefix(t.route_prefix) + + built_apps.append( + build_app( + t.target, + name=t.name, + route_prefix=t.route_prefix, + logging_config=t.logging_config, + make_deployment_handle=make_local_deployment_handle + if _local_testing_mode + else None, + default_runtime_env=ray.get_runtime_context().runtime_env + if not _local_testing_mode + else None, + ) + ) + + if _local_testing_mode: + # implicitly use the last target's logging config (if provided) in local testing mode + logging_config = t.logging_config or LoggingConfig() + if not isinstance(logging_config, LoggingConfig): + logging_config = LoggingConfig(**(logging_config or {})) + + configure_component_logger( + component_name="local_test", + component_id="-", + logging_config=logging_config, + stream_handler_only=True, + ) + return [b.deployment_handles[b.ingress_deployment_name] for b in built_apps] + else: + client = _private_api.serve_start( + http_options={"location": "EveryNode"}, + global_logging_config=None, + ) + + # Record after Ray has been started. + ServeUsageTag.API_VERSION.record("v2") + + handles = client.deploy_applications( + built_apps, + wait_for_ingress_deployment_creation=wait_for_ingress_deployment_creation, + wait_for_applications_running=wait_for_applications_running, + ) + + client.wait_for_proxies_serving( + wait_for_applications_running=wait_for_applications_running + ) + return handles + + +@PublicAPI(stability="stable") +def _run( + target: Application, + *, + _blocking: bool = True, + name: str = SERVE_DEFAULT_APP_NAME, + route_prefix: Optional[str] = "/", + logging_config: Optional[Union[Dict, LoggingConfig]] = None, + _local_testing_mode: bool = False, +) -> DeploymentHandle: + """Run an application and return a handle to its ingress deployment. + + This is only used internally with the _blocking not totally blocking the following + code indefinitely until Ctrl-C'd. + """ + return _run_many( + [ + RunTarget( + target=target, + name=name, + route_prefix=route_prefix, + logging_config=logging_config, + ) + ], + wait_for_applications_running=_blocking, + _local_testing_mode=_local_testing_mode, + )[0] + + +@DeveloperAPI +def run_many( + targets: Sequence[RunTarget], + blocking: bool = False, + wait_for_ingress_deployment_creation: bool = True, + wait_for_applications_running: bool = True, + _local_testing_mode: bool = False, +) -> List[DeploymentHandle]: + """Run many applications and return the handles to their ingress deployments. + + Args: + targets: + A sequence of `RunTarget`, + each containing information about an application to deploy. + blocking: Whether this call should be blocking. If True, it + will loop and log status until Ctrl-C'd. + wait_for_ingress_deployment_creation: Whether to wait for the ingress + deployments to be created. + wait_for_applications_running: Whether to wait for the applications to be + running. Note that this effectively implies + `wait_for_ingress_deployment_creation=True`, + because the ingress deployments must be created + before the applications can be running. + + Returns: + List[DeploymentHandle]: A list of handles that can be used + to call the applications. + """ + handles = _run_many( + targets, + wait_for_ingress_deployment_creation=wait_for_ingress_deployment_creation, + wait_for_applications_running=wait_for_applications_running, + _local_testing_mode=_local_testing_mode, + ) + + if blocking: + wait_for_interrupt() + + return handles + + +@PublicAPI(stability="stable") +def run( + target: Application, + blocking: bool = False, + name: str = SERVE_DEFAULT_APP_NAME, + route_prefix: Optional[str] = "/", + logging_config: Optional[Union[Dict, LoggingConfig]] = None, + _local_testing_mode: bool = False, +) -> DeploymentHandle: + """Run an application and return a handle to its ingress deployment. + + The application is returned by `Deployment.bind()`. Example: + + .. code-block:: python + + handle = serve.run(MyDeployment.bind()) + ray.get(handle.remote()) + + Args: + target: + A Serve application returned by `Deployment.bind()`. + blocking: Whether this call should be blocking. If True, it + will loop and log status until Ctrl-C'd. + name: Application name. If not provided, this will be the only + application running on the cluster (it will delete all others). + route_prefix: Route prefix for HTTP requests. Defaults to '/'. + If `None` is passed, the application will not be exposed over HTTP + (this may be useful if you only want the application to be exposed via + gRPC or a `DeploymentHandle`). + logging_config: Application logging config. If provided, the config will + be applied to all deployments which doesn't have logging config. + + Returns: + DeploymentHandle: A handle that can be used to call the application. + """ + handle = _run( + target=target, + name=name, + route_prefix=route_prefix, + logging_config=logging_config, + _local_testing_mode=_local_testing_mode, + ) + + if blocking: + wait_for_interrupt() + + return handle + + +@PublicAPI(stability="stable") +def delete(name: str, _blocking: bool = True): + """Delete an application by its name. + + Deletes the app with all corresponding deployments. + """ + client = _get_global_client() + client.delete_apps([name], blocking=_blocking) + + +@PublicAPI(stability="beta") +def multiplexed( + func: Optional[Callable[..., Any]] = None, max_num_models_per_replica: int = 3 +): + """Wrap a callable or method used to load multiplexed models in a replica. + + The function can be standalone function or a method of a class. The + function must have exactly one argument, the model id of type `str` for the + model to be loaded. + + It is required to define the function with `async def` and the function must be + an async function. It is recommended to define coroutines for long running + IO tasks in the function to avoid blocking the event loop. + + The multiplexed function is called to load a model with the given model ID when + necessary. + + When the number of models in one replica is larger than max_num_models_per_replica, + the models will be unloaded using an LRU policy. + + If you want to release resources after the model is loaded, you can define + a `__del__` method in your model class. The `__del__` method will be called when + the model is unloaded. + + Example: + + .. code-block:: python + + from ray import serve + + @serve.deployment + class MultiplexedDeployment: + + def __init__(self): + # Define s3 base path to load models. + self.s3_base_path = "s3://my_bucket/my_models" + + @serve.multiplexed(max_num_models_per_replica=5) + async def load_model(self, model_id: str) -> Any: + # Load model with the given tag + # You can use any model loading library here + # and return the loaded model. load_from_s3 is + # a placeholder function. + return load_from_s3(model_id) + + async def __call__(self, request): + # Get the model_id from the request context. + model_id = serve.get_multiplexed_model_id() + # Load the model for the requested model_id. + # If the model is already cached locally, + # this will just be a dictionary lookup. + model = await self.load_model(model_id) + return model(request) + + + Args: + max_num_models_per_replica: the maximum number of models + to be loaded on each replica. By default, it is 3, which + means that each replica can cache up to 3 models. You can + set it to a larger number if you have enough memory on + the node resource, in opposite, you can set it to a smaller + number if you want to save memory on the node resource. + """ + + if func is not None: + if not callable(func): + raise TypeError( + "The `multiplexed` decorator must be used with a function or method." + ) + + # TODO(Sihan): Make the API accept the sync function as well. + # https://github.com/ray-project/ray/issues/35356 + if not inspect.iscoroutinefunction(func): + raise TypeError( + "@serve.multiplexed can only be used to decorate async " + "functions or methods." + ) + signature = inspect.signature(func) + if len(signature.parameters) == 0 or len(signature.parameters) > 2: + raise TypeError( + "@serve.multiplexed can only be used to decorate functions or methods " + "with at least one 'model_id: str' argument." + ) + + if not isinstance(max_num_models_per_replica, int): + raise TypeError("max_num_models_per_replica must be an integer.") + + if max_num_models_per_replica != -1 and max_num_models_per_replica <= 0: + raise ValueError("max_num_models_per_replica must be positive.") + + def _multiplex_decorator(func: Callable): + @wraps(func) + async def _multiplex_wrapper(*args): + args_check_error_msg = ( + "Functions decorated with `@serve.multiplexed` must take exactly one" + "the multiplexed model ID (str), but got {}" + ) + if not args: + raise TypeError( + args_check_error_msg.format("no arguments are provided.") + ) + self = extract_self_if_method_call(args, func) + + # User defined multiplexed function can be a standalone function or a + # method of a class. If it is a method of a class, the first argument + # is self. + if self is None: + if len(args) != 1: + raise TypeError( + args_check_error_msg.format("more than one arguments.") + ) + multiplex_object = func + model_id = args[0] + else: + # count self as an argument + if len(args) != 2: + raise TypeError( + args_check_error_msg.format("more than one arguments.") + ) + multiplex_object = self + model_id = args[1] + multiplex_attr = "__serve_multiplex_wrapper" + # If the multiplexed function is called for the first time, + # create a model multiplex wrapper and cache it in the multiplex object. + if not hasattr(multiplex_object, multiplex_attr): + model_multiplex_wrapper = _ModelMultiplexWrapper( + func, self, max_num_models_per_replica + ) + setattr(multiplex_object, multiplex_attr, model_multiplex_wrapper) + else: + model_multiplex_wrapper = getattr(multiplex_object, multiplex_attr) + return await model_multiplex_wrapper.load_model(model_id) + + return _multiplex_wrapper + + return _multiplex_decorator(func) if callable(func) else _multiplex_decorator + + +@PublicAPI(stability="beta") +def get_multiplexed_model_id() -> str: + """Get the multiplexed model ID for the current request. + + This is used with a function decorated with `@serve.multiplexed` + to retrieve the model ID for the current request. + + .. code-block:: python + + import ray + from ray import serve + import requests + + # Set the multiplexed model id with the key + # "ray_serve_multiplexed_model_id" in the request + # headers when sending requests to the http proxy. + requests.get("http://localhost:8000", + headers={"ray_serve_multiplexed_model_id": "model_1"}) + + # This can also be set when using `DeploymentHandle`. + handle.options(multiplexed_model_id="model_1").remote("blablabla") + + # In your deployment code, you can retrieve the model id from + # `get_multiplexed_model_id()`. + @serve.deployment + def my_deployment_function(request): + assert serve.get_multiplexed_model_id() == "model_1" + """ + _request_context = ray.serve.context._get_serve_request_context() + return _request_context.multiplexed_model_id + + +@PublicAPI(stability="alpha") +def status() -> ServeStatus: + """Get the status of Serve on the cluster. + + Includes status of all HTTP Proxies, all active applications, and + their deployments. + + .. code-block:: python + + @serve.deployment(num_replicas=2) + class MyDeployment: + pass + + serve.run(MyDeployment.bind()) + status = serve.status() + assert status.applications["default"].status == "RUNNING" + """ + + client = _get_global_client(raise_if_no_controller_running=False) + if client is None: + # Serve has not started yet + return ServeStatus() + + ServeUsageTag.SERVE_STATUS_API_USED.record("1") + details = ServeInstanceDetails(**client.get_serve_details()) + return details._get_status() + + +@PublicAPI(stability="alpha") +def get_app_handle(name: str) -> DeploymentHandle: + """Get a handle to the application's ingress deployment by name. + + Args: + name: Name of application to get a handle to. + + Raises: + RayServeException: If no Serve controller is running, or if the + application does not exist. + + .. code-block:: python + + import ray + from ray import serve + + @serve.deployment + def f(val: int) -> int: + return val * 2 + + serve.run(f.bind(), name="my_app") + handle = serve.get_app_handle("my_app") + assert handle.remote(3).result() == 6 + """ + + client = _get_global_client() + ingress = ray.get(client._controller.get_ingress_deployment_name.remote(name)) + if ingress is None: + raise RayServeException(f"Application '{name}' does not exist.") + + ServeUsageTag.SERVE_GET_APP_HANDLE_API_USED.record("1") + # There is no need to check if the deployment exists since the + # deployment name was just fetched from the controller + return client.get_handle(ingress, name, check_exists=False) + + +@DeveloperAPI +def get_deployment_handle( + deployment_name: str, + app_name: Optional[str] = None, + _check_exists: bool = True, + _record_telemetry: bool = True, +) -> DeploymentHandle: + """Get a handle to a deployment by name. + + This is a developer API and is for advanced Ray users and library developers. + + Args: + deployment_name: Name of deployment to get a handle to. + app_name: Application in which deployment resides. If calling + from inside a Serve application and `app_name` is not + specified, this will default to the application from which + this API is called. + + Raises: + RayServeException: If no Serve controller is running, or if + calling from outside a Serve application and no application + name is specified. + + The following example gets the handle to the ingress deployment of + an application, which is equivalent to using `serve.get_app_handle`. + + .. testcode:: + + import ray + from ray import serve + + @serve.deployment + def f(val: int) -> int: + return val * 2 + + serve.run(f.bind(), name="my_app") + handle = serve.get_deployment_handle("f", app_name="my_app") + assert handle.remote(3).result() == 6 + + serve.shutdown() + + The following example demonstrates how you can use this API to get + the handle to a non-ingress deployment in an application. + + .. testcode:: + + import ray + from ray import serve + from ray.serve.handle import DeploymentHandle + + @serve.deployment + class Multiplier: + def __init__(self, multiple: int): + self._multiple = multiple + + def __call__(self, val: int) -> int: + return val * self._multiple + + @serve.deployment + class Adder: + def __init__(self, handle: DeploymentHandle, increment: int): + self._handle = handle + self._increment = increment + + async def __call__(self, val: int) -> int: + return await self._handle.remote(val) + self._increment + + + # The app calculates 2 * x + 3 + serve.run(Adder.bind(Multiplier.bind(2), 3), name="math_app") + handle = serve.get_app_handle("math_app") + assert handle.remote(5).result() == 13 + + # Get handle to Multiplier only + handle = serve.get_deployment_handle("Multiplier", app_name="math_app") + assert handle.remote(5).result() == 10 + + serve.shutdown() + """ + + client = _get_global_client() + + internal_replica_context = _get_internal_replica_context() + if app_name is None: + if internal_replica_context is None: + raise RayServeException( + "Please specify an application name when getting a deployment handle " + "outside of a Serve application." + ) + else: + app_name = internal_replica_context.app_name + + if _record_telemetry: + ServeUsageTag.SERVE_GET_DEPLOYMENT_HANDLE_API_USED.record("1") + + handle: DeploymentHandle = client.get_handle( + deployment_name, app_name, check_exists=_check_exists + ) + + # Track handle creation if called from within a replica + if ( + internal_replica_context is not None + and internal_replica_context._handle_registration_callback is not None + ): + internal_replica_context._handle_registration_callback(handle.deployment_id) + + return handle diff --git a/lib/python3.12/site-packages/ray/serve/batching.py b/lib/python3.12/site-packages/ray/serve/batching.py new file mode 100644 index 0000000000000000000000000000000000000000..ab16fe47e962c42b411543c79cbcc0fb443b66ec --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/batching.py @@ -0,0 +1,770 @@ +import asyncio +import io +import logging +import time +from collections import deque +from dataclasses import dataclass +from functools import wraps +from inspect import isasyncgenfunction, iscoroutinefunction +from typing import ( + Any, + AsyncGenerator, + Callable, + Coroutine, + Dict, + Generic, + Iterable, + List, + Literal, + Optional, + Protocol, + Set, + Tuple, + TypeVar, + overload, +) + +from ray import serve +from ray._common.signature import extract_signature, flatten_args, recover_args +from ray._common.utils import get_or_create_event_loop +from ray.serve._private.constants import SERVE_LOGGER_NAME +from ray.serve._private.utils import extract_self_if_method_call +from ray.serve.exceptions import RayServeException +from ray.util.annotations import PublicAPI + +logger = logging.getLogger(SERVE_LOGGER_NAME) + + +# The user can return these values in their streaming batch handler function to +# indicate that a request is finished, so Serve can terminate the request. +USER_CODE_STREAMING_SENTINELS = [StopIteration, StopAsyncIteration] + + +@dataclass +class _SingleRequest: + self_arg: Any + flattened_args: List[Any] + future: asyncio.Future + request_context: serve.context._RequestContext + + +@dataclass +class _GeneratorResult: + result: Any + next_future: asyncio.Future + + +@dataclass +class _RuntimeSummaryStatistics: + start_times: List[float] + + @property + def min_start_time(self) -> Optional[float]: + return min(self.start_times) if self.start_times else None + + @property + def mean_start_time(self) -> Optional[float]: + return ( + sum(self.start_times) / len(self.start_times) if self.start_times else None + ) + + @property + def max_start_time(self) -> Optional[float]: + return max(self.start_times) if self.start_times else None + + @property + def num_requests(self) -> int: + return len(self.start_times) + + +def _batch_args_kwargs( + list_of_flattened_args: List[List[Any]], +) -> Tuple[Tuple[Any], Dict[Any, Any]]: + """Batch a list of flatten args and returns regular args and kwargs""" + # Ray's flatten arg format is a list with alternating key and values + # e.g. args=(1, 2), kwargs={"key": "val"} got turned into + # [None, 1, None, 2, "key", "val"] + arg_lengths = {len(args) for args in list_of_flattened_args} + assert ( + len(arg_lengths) == 1 + ), "All batch requests should have the same number of parameters." + arg_length = arg_lengths.pop() + + batched_flattened_args = [] + for idx in range(arg_length): + if idx % 2 == 0: + batched_flattened_args.append(list_of_flattened_args[0][idx]) + else: + batched_flattened_args.append( + [item[idx] for item in list_of_flattened_args] + ) + + return recover_args(batched_flattened_args) + + +class _BatchQueue: + def __init__( + self, + max_batch_size: int, + batch_wait_timeout_s: float, + max_concurrent_batches: int, + handle_batch_func: Optional[Callable] = None, + ) -> None: + """Async queue that accepts individual items and returns batches. + + Respects max_batch_size and batch_wait_timeout_s; a batch will be returned when + max_batch_size elements are available or the timeout has passed since + the previous get. + + If handle_batch_func is passed in, a background coroutine will run to + poll from the queue and call handle_batch_func on the results. + + Cannot be pickled. + + Arguments: + max_batch_size: max number of elements to return in a batch. + batch_wait_timeout_s: time to wait before returning an incomplete + batch. + max_concurrent_batches: max number of batches to run concurrently. + handle_batch_func(Optional[Callable]): callback to run in the + background to handle batches if provided. + """ + self.queue: asyncio.Queue[_SingleRequest] = asyncio.Queue() + self.max_batch_size = max_batch_size + self.batch_wait_timeout_s = batch_wait_timeout_s + self.max_concurrent_batches = max_concurrent_batches + self.semaphore = asyncio.Semaphore(max_concurrent_batches) + self.requests_available_event = asyncio.Event() + self.tasks: Set[asyncio.Task] = set() + + # Used for observability. + self.curr_iteration_start_times: Dict[asyncio.Task, float] = {} + + self._handle_batch_task = None + self._loop = get_or_create_event_loop() + if handle_batch_func is not None: + self._handle_batch_task = self._loop.create_task( + self._process_batches(handle_batch_func) + ) + self._warn_if_max_batch_size_exceeds_max_ongoing_requests() + + def _warn_if_max_batch_size_exceeds_max_ongoing_requests(self): + """Helper to check whether the max_batch_size is bounded. + + Log a warning to configure `max_ongoing_requests` if it's bounded. + """ + max_ongoing_requests = ( + serve.get_replica_context()._deployment_config.max_ongoing_requests + ) + if max_ongoing_requests < self.max_batch_size * self.max_concurrent_batches: + logger.warning( + f"`max_batch_size` ({self.max_batch_size}) * `max_concurrent_batches` " + f"({self.max_concurrent_batches}) is larger than `max_ongoing_requests` " + f"({max_ongoing_requests}). This means the replica will never achieve " + "the configured `max_batch_size` concurrently. Please update " + "`max_ongoing_requests` to be >= `max_batch_size` * `max_concurrent_batches`." + ) + + def set_max_batch_size(self, new_max_batch_size: int) -> None: + """Updates queue's max_batch_size.""" + self.max_batch_size = new_max_batch_size + self._warn_if_max_batch_size_exceeds_max_ongoing_requests() + + def put(self, request: Tuple[_SingleRequest, asyncio.Future]) -> None: + self.queue.put_nowait(request) + self.requests_available_event.set() + + async def wait_for_batch(self) -> List[_SingleRequest]: + """Wait for batch respecting self.max_batch_size and self.timeout_s. + + Returns a batch of up to self.max_batch_size items. Waits for up to + to self.timeout_s after receiving the first request that will be in + the next batch. After the timeout, returns as many items as are ready. + + Always returns a batch with at least one item - will block + indefinitely until an item comes in. + """ + + batch = [] + batch.append(await self.queue.get()) + + # Cache current max_batch_size and batch_wait_timeout_s for this batch. + max_batch_size = self.max_batch_size + batch_wait_timeout_s = self.batch_wait_timeout_s + + # Wait self.timeout_s seconds for new queue arrivals. + batch_start_time = time.time() + while True: + remaining_batch_time_s = max( + batch_wait_timeout_s - (time.time() - batch_start_time), 0 + ) + try: + # Wait for new arrivals. + await asyncio.wait_for( + self.requests_available_event.wait(), remaining_batch_time_s + ) + except asyncio.TimeoutError: + pass + + # Add all new arrivals to the batch. + while len(batch) < max_batch_size and not self.queue.empty(): + batch.append(self.queue.get_nowait()) + + # Only clear the put event if the queue is empty. If it's not empty + # we can start constructing a new batch immediately in the next loop. + # The code that puts items into the queue runs on the same event loop + # as this code, so there's no race condition between the time we + # get objects in the queue (and clear the event) and when objects + # get added to the queue. + if self.queue.empty(): + self.requests_available_event.clear() + + if ( + time.time() - batch_start_time >= batch_wait_timeout_s + or len(batch) >= max_batch_size + ): + break + + return batch + + def _validate_results( + self, results: Iterable[Any], input_batch_length: int + ) -> None: + if len(results) != input_batch_length: + raise RayServeException( + "Batched function doesn't preserve batch size. " + f"The input list has length {input_batch_length} but the " + f"returned list has length {len(results)}." + ) + + async def _consume_func_generator( + self, + func_generator: AsyncGenerator, + initial_futures: List[asyncio.Future], + input_batch_length: int, + ) -> None: + """Consumes batch function generator. + + This function only runs if the function decorated with @serve.batch + is a generator. + """ + + FINISHED_TOKEN = None + + try: + futures = deque(initial_futures) + assert len(futures) == input_batch_length + + async for results in func_generator: + self._validate_results(results, input_batch_length) + for idx in range(input_batch_length): + result, future = results[idx], futures[0] + + if future is FINISHED_TOKEN: + # This caller has already terminated. + futures.append(FINISHED_TOKEN) + elif result in USER_CODE_STREAMING_SENTINELS: + # User's code returned sentinel. No values left + # for caller. Terminate iteration for caller. + _set_exception_if_not_done(future, StopAsyncIteration) + futures.append(FINISHED_TOKEN) + else: + next_future = get_or_create_event_loop().create_future() + _set_result_if_not_done( + future, _GeneratorResult(result, next_future) + ) + futures.append(next_future) + + # Remove processed future. We remove the future at the very + # end of the loop to ensure that if an exception occurs, + # all pending futures will get set in the `except` block. + futures.popleft() + + for future in futures: + if future is not FINISHED_TOKEN: + _set_exception_if_not_done(future, StopAsyncIteration) + except Exception as e: + for future in futures: + if future is not FINISHED_TOKEN: + _set_exception_if_not_done(future, e) + + async def _assign_func_results( + self, + func_future: asyncio.Future, + futures: List[asyncio.Future], + input_batch_length: int, + ): + """Assigns func's results to the list of futures.""" + + try: + results = await func_future + self._validate_results(results, input_batch_length) + for result, future in zip(results, futures): + _set_result_if_not_done(future, result) + except Exception as e: + for future in futures: + _set_exception_if_not_done(future, e) + + async def _process_batches(self, func: Callable) -> None: + """Loops infinitely and processes queued request batches.""" + # When asyncio task is created, the task will inherit the request context from the current context. + # So we unset the request context so the current context is not inherited by the task, _process_batch. + serve.context._unset_request_context() + while not self._loop.is_closed(): + batch = await self.wait_for_batch() + promise = self._process_batch(func, batch) + task = asyncio.create_task(promise) + self.tasks.add(task) + self.curr_iteration_start_times[task] = time.time() + task.add_done_callback(self._handle_completed_task) + + async def _process_batch(self, func: Callable, batch: List[_SingleRequest]) -> None: + """Processes queued request batch.""" + # NOTE: this semaphore caps the number of concurrent batches specified by `max_concurrent_batches` + async with self.semaphore: + # Remove requests that have been cancelled from the batch. If + # all requests have been cancelled, simply return and wait for + # the next batch. + batch = [req for req in batch if not req.future.cancelled()] + if len(batch) == 0: + return + + futures = [item.future for item in batch] + + # Most of the logic in the function should be wrapped in this try- + # except block, so the futures' exceptions can be set if an exception + # occurs. Otherwise, the futures' requests may hang indefinitely. + try: + self_arg = batch[0].self_arg + args, kwargs = _batch_args_kwargs( + [item.flattened_args for item in batch] + ) + + # Method call. + if self_arg is not None: + func_future_or_generator = func(self_arg, *args, **kwargs) + # Normal function call. + else: + func_future_or_generator = func(*args, **kwargs) + + # Add individual request context to the batch request context + serve.context._set_batch_request_context( + [req.request_context for req in batch] + ) + + if isasyncgenfunction(func): + func_generator = func_future_or_generator + await self._consume_func_generator( + func_generator, futures, len(batch) + ) + else: + func_future = func_future_or_generator + await self._assign_func_results(func_future, futures, len(batch)) + + # Reset the batch request context after the batch is processed + serve.context._set_batch_request_context([]) + except Exception as e: + logger.exception("_process_batch ran into an unexpected exception.") + + for future in futures: + _set_exception_if_not_done(future, e) + + def _handle_completed_task(self, task: asyncio.Task) -> None: + self.tasks.remove(task) + del self.curr_iteration_start_times[task] + self._log_if_exception(task.exception()) + + @staticmethod + def _log_if_exception(exception_maybe: Optional[BaseException]) -> None: + if exception_maybe is not None: + if isinstance(exception_maybe, asyncio.CancelledError): + logger.debug("Task was cancelled") + else: + logger.exception("Task failed unexpectedly") + + def __del__(self): + if ( + self._handle_batch_task is None + or not get_or_create_event_loop().is_running() + ): + return + + # TODO(edoakes): although we try to gracefully shutdown here, it still + # causes some errors when the process exits due to the asyncio loop + # already being destroyed. + self._handle_batch_task.cancel() + + +class _LazyBatchQueueWrapper: + """Stores a _BatchQueue and updates its settings. + + _BatchQueue cannot be pickled, you must construct it lazily + at runtime inside a replica. This class initializes a queue only upon + first access. + """ + + def __init__( + self, + max_batch_size: int = 10, + batch_wait_timeout_s: float = 0.0, + max_concurrent_batches: int = 1, + handle_batch_func: Optional[Callable] = None, + ): + self._queue: Optional[_BatchQueue] = None + self.max_batch_size = max_batch_size + self.batch_wait_timeout_s = batch_wait_timeout_s + self.max_concurrent_batches = max_concurrent_batches + self.handle_batch_func = handle_batch_func + + @property + def queue(self) -> _BatchQueue: + """Returns _BatchQueue. + + Initializes queue when called for the first time. + """ + if self._queue is None: + self._queue = _BatchQueue( + self.max_batch_size, + self.batch_wait_timeout_s, + self.max_concurrent_batches, + self.handle_batch_func, + ) + return self._queue + + def set_max_batch_size(self, new_max_batch_size: int) -> None: + """Updates queue's max_batch_size.""" + + self.max_batch_size = new_max_batch_size + + if self._queue is not None: + self._queue.set_max_batch_size(new_max_batch_size) + + def set_batch_wait_timeout_s(self, new_batch_wait_timeout_s: float) -> None: + self.batch_wait_timeout_s = new_batch_wait_timeout_s + + if self._queue is not None: + self._queue.batch_wait_timeout_s = new_batch_wait_timeout_s + + def get_max_batch_size(self) -> int: + return self.max_batch_size + + def get_batch_wait_timeout_s(self) -> float: + return self.batch_wait_timeout_s + + def _get_curr_iteration_start_times(self) -> _RuntimeSummaryStatistics: + """Gets summary statistics of current iteration's start times.""" + return _RuntimeSummaryStatistics( + list(self.queue.curr_iteration_start_times.values()) + ) + + async def _is_batching_task_alive(self) -> bool: + """Gets whether default _BatchQueue's background task is alive. + + Returns False if the batch handler doesn't use a default _BatchQueue. + """ + + if hasattr(self.queue, "_handle_batch_task"): + return not self.queue._handle_batch_task.done() + else: + return False + + async def _get_handling_task_stack(self) -> Optional[str]: + """Gets the stack for the default _BatchQueue's background task. + + Returns empty string if the batch handler doesn't use a default _BatchQueue. + """ + + if hasattr(self.queue, "_handle_batch_task"): + str_buffer = io.StringIO() + self.queue._handle_batch_task.print_stack(file=str_buffer) + return str_buffer.getvalue() + else: + return None + + +def _validate_max_batch_size(max_batch_size): + if not isinstance(max_batch_size, int): + if isinstance(max_batch_size, float) and max_batch_size.is_integer(): + max_batch_size = int(max_batch_size) + else: + raise TypeError( + f"max_batch_size must be integer >= 1, got {max_batch_size}" + ) + + if max_batch_size < 1: + raise ValueError( + f"max_batch_size must be an integer >= 1, got {max_batch_size}" + ) + + +def _validate_batch_wait_timeout_s(batch_wait_timeout_s): + if not isinstance(batch_wait_timeout_s, (float, int)): + raise TypeError( + f"batch_wait_timeout_s must be a float >= 0, got {batch_wait_timeout_s}" + ) + + if batch_wait_timeout_s < 0: + raise ValueError( + f"batch_wait_timeout_s must be a float >= 0, got {batch_wait_timeout_s}" + ) + + +def _validate_max_concurrent_batches(max_concurrent_batches: int) -> None: + if not isinstance(max_concurrent_batches, int) or max_concurrent_batches < 1: + raise TypeError( + f"max_concurrent_batches must be an integer >= 1, got {max_concurrent_batches}" + ) + + +SelfType = TypeVar("SelfType", contravariant=True) +T = TypeVar("T") +R = TypeVar("R") + + +class _SyncBatchingMethod(Protocol, Generic[SelfType, T, R]): + def __call__(self, self_: SelfType, __batch: List[T], /) -> List[R]: + ... + + +class _AsyncBatchingMethod(Protocol, Generic[SelfType, T, R]): + async def __call__(self, self_: SelfType, __batch: List[T], /) -> List[R]: + ... + + +@overload # Sync function for `batch` called WITHOUT arguments +def batch(_sync_func: Callable[[List[T]], List[R]], /) -> Callable[[T], R]: + ... + + +@overload # Async function for `batch` called WITHOUT arguments +def batch( + _async_func: Callable[[List[T]], Coroutine[Any, Any, List[R]]], / +) -> Callable[[T], Coroutine[Any, Any, R]]: + ... + + +@overload # Sync method for `batch` called WITHOUT arguments +def batch( + _sync_meth: _SyncBatchingMethod[SelfType, T, R], / +) -> Callable[[SelfType, T], R]: + ... + + +@overload # Async method for `batch` called WITHOUT arguments +def batch( + _async_meth: _AsyncBatchingMethod[SelfType, T, R], / +) -> Callable[[SelfType, T], Coroutine[Any, Any, R]]: + ... + + +@overload # `batch` called WITH arguments +def batch( + _: Literal[None] = None, + /, + max_batch_size: int = 10, + batch_wait_timeout_s: float = 0.01, + max_concurrent_batches: int = 1, +) -> "_BatchDecorator": + ... + + +class _BatchDecorator(Protocol): + """Descibes behaviour of decorator produced by calling `batch` with arguments""" + + @overload # Sync function + def __call__(self, _sync_func: Callable[[List[T]], List[R]], /) -> Callable[[T], R]: + ... + + @overload # Async function + def __call__( + self, _async_func: Callable[[List[T]], Coroutine[Any, Any, List[R]]], / + ) -> Callable[[T], Coroutine[Any, Any, R]]: + ... + + @overload # Sync method + def __call__( + self, _sync_meth: _SyncBatchingMethod[SelfType, T, R], / + ) -> Callable[[SelfType, T], R]: + ... + + @overload # Async method + def __call__( + self, _async_meth: _AsyncBatchingMethod[SelfType, T, R], / + ) -> Callable[[SelfType, T], Coroutine[Any, Any, R]]: + ... + + +@PublicAPI(stability="stable") +def batch( + _func: Optional[Callable] = None, + /, + max_batch_size: int = 10, + batch_wait_timeout_s: float = 0.01, + max_concurrent_batches: int = 1, +) -> Callable: + """Converts a function to asynchronously handle batches. + + The function can be a standalone function or a class method. In both + cases, the function must be `async def` and take a list of objects as + its sole argument and return a list of the same length as a result. + + When invoked, the caller passes a single object. These will be batched + and executed asynchronously once there is a batch of `max_batch_size` + or `batch_wait_timeout_s` has elapsed, whichever occurs first. + + `max_batch_size` and `batch_wait_timeout_s` can be updated using setter + methods from the batch_handler (`set_max_batch_size` and + `set_batch_wait_timeout_s`). + + Example: + + .. code-block:: python + + from ray import serve + from starlette.requests import Request + + @serve.deployment + class BatchedDeployment: + @serve.batch(max_batch_size=10, batch_wait_timeout_s=0.1) + async def batch_handler(self, requests: List[Request]) -> List[str]: + response_batch = [] + for r in requests: + name = (await requests.json())["name"] + response_batch.append(f"Hello {name}!") + + return response_batch + + def update_batch_params(self, max_batch_size, batch_wait_timeout_s): + self.batch_handler.set_max_batch_size(max_batch_size) + self.batch_handler.set_batch_wait_timeout_s(batch_wait_timeout_s) + + async def __call__(self, request: Request): + return await self.batch_handler(request) + + app = BatchedDeployment.bind() + + Arguments: + max_batch_size: the maximum batch size that will be executed in + one call to the underlying function. + batch_wait_timeout_s: the maximum duration to wait for + `max_batch_size` elements before running the current batch. + max_concurrent_batches: the maximum number of batches that can be + executed concurrently. If the number of concurrent batches exceeds + this limit, the batch handler will wait for a batch to complete + before sending the next batch to the underlying function. + """ + # `_func` will be None in the case when the decorator is parametrized. + # See the comment at the end of this function for a detailed explanation. + if _func is not None: + if not callable(_func): + raise TypeError( + "@serve.batch can only be used to decorate functions or methods." + ) + + if not iscoroutinefunction(_func): + raise TypeError("Functions decorated with @serve.batch must be 'async def'") + + _validate_max_batch_size(max_batch_size) + _validate_batch_wait_timeout_s(batch_wait_timeout_s) + _validate_max_concurrent_batches(max_concurrent_batches) + + def _batch_decorator(_func): + lazy_batch_queue_wrapper = _LazyBatchQueueWrapper( + max_batch_size, + batch_wait_timeout_s, + max_concurrent_batches, + _func, + ) + + async def batch_handler_generator( + first_future: asyncio.Future, + ) -> AsyncGenerator: + """Generator that handles generator batch functions.""" + + future = first_future + while True: + try: + async_response: _GeneratorResult = await future + future = async_response.next_future + yield async_response.result + except StopAsyncIteration: + break + + def enqueue_request(args, kwargs) -> asyncio.Future: + flattened_args: List = flatten_args(extract_signature(_func), args, kwargs) + + # If the function is a method, remove self as an argument. + self = extract_self_if_method_call(args, _func) + if self is not None: + flattened_args = flattened_args[2:] + + batch_queue = lazy_batch_queue_wrapper.queue + + future = get_or_create_event_loop().create_future() + request_context = serve.context._get_serve_request_context() + batch_queue.put( + _SingleRequest(self, flattened_args, future, request_context) + ) + return future + + @wraps(_func) + def generator_batch_wrapper(*args, **kwargs): + first_future = enqueue_request(args, kwargs) + return batch_handler_generator(first_future) + + @wraps(_func) + async def batch_wrapper(*args, **kwargs): + # This will raise if the underlying call raised an exception. + return await enqueue_request(args, kwargs) + + if isasyncgenfunction(_func): + wrapper = generator_batch_wrapper + else: + wrapper = batch_wrapper + + # We store the lazy_batch_queue_wrapper's getters and setters as + # batch_wrapper attributes, so they can be accessed in user code. + wrapper._get_max_batch_size = lazy_batch_queue_wrapper.get_max_batch_size + wrapper._get_batch_wait_timeout_s = ( + lazy_batch_queue_wrapper.get_batch_wait_timeout_s + ) + wrapper.set_max_batch_size = lazy_batch_queue_wrapper.set_max_batch_size + wrapper.set_batch_wait_timeout_s = ( + lazy_batch_queue_wrapper.set_batch_wait_timeout_s + ) + + # Store debugging methods in the lazy_batch_queue wrapper + wrapper._get_curr_iteration_start_times = ( + lazy_batch_queue_wrapper._get_curr_iteration_start_times + ) + wrapper._is_batching_task_alive = ( + lazy_batch_queue_wrapper._is_batching_task_alive + ) + wrapper._get_handling_task_stack = ( + lazy_batch_queue_wrapper._get_handling_task_stack + ) + + return wrapper + + # Unfortunately, this is required to handle both non-parametrized + # (@serve.batch) and parametrized (@serve.batch(**kwargs)) usage. + # In the former case, `serve.batch` will be called with the underlying + # function as the sole argument. In the latter case, it will first be + # called with **kwargs, then the result of that call will be called + # with the underlying function as the sole argument (i.e., it must be a + # "decorator factory."). + return _batch_decorator(_func) if callable(_func) else _batch_decorator + + +def _set_result_if_not_done(future: asyncio.Future, result: Any): + """Sets the future's result if the future is not done.""" + + if not future.done(): + future.set_result(result) + + +def _set_exception_if_not_done(future: asyncio.Future, exception: Any): + """Sets the future's exception if the future is not done.""" + + if not future.done(): + future.set_exception(exception) diff --git a/lib/python3.12/site-packages/ray/serve/config.py b/lib/python3.12/site-packages/ray/serve/config.py new file mode 100644 index 0000000000000000000000000000000000000000..6aa7ac54317b95ffdce1c4e36f572928fba43a70 --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/config.py @@ -0,0 +1,684 @@ +import json +import logging +import warnings +from dataclasses import dataclass +from enum import Enum +from typing import Any, Callable, Dict, List, Optional, Union + +from ray import cloudpickle +from ray._common.pydantic_compat import ( + BaseModel, + Field, + NonNegativeFloat, + NonNegativeInt, + PositiveFloat, + PositiveInt, + PrivateAttr, + validator, +) +from ray._common.utils import import_attr, import_module_and_attr + +# Import types needed for AutoscalingContext +from ray.serve._private.common import DeploymentID, ReplicaID, TimeSeries +from ray.serve._private.constants import ( + DEFAULT_AUTOSCALING_POLICY_NAME, + DEFAULT_GRPC_PORT, + DEFAULT_HTTP_HOST, + DEFAULT_HTTP_PORT, + DEFAULT_REQUEST_ROUTER_PATH, + DEFAULT_REQUEST_ROUTING_STATS_PERIOD_S, + DEFAULT_REQUEST_ROUTING_STATS_TIMEOUT_S, + DEFAULT_TARGET_ONGOING_REQUESTS, + DEFAULT_UVICORN_KEEP_ALIVE_TIMEOUT_S, + SERVE_LOGGER_NAME, +) +from ray.serve._private.utils import validate_ssl_config +from ray.util.annotations import Deprecated, PublicAPI + +logger = logging.getLogger(SERVE_LOGGER_NAME) + + +@PublicAPI(stability="alpha") +@dataclass +class AutoscalingContext: + """Rich context provided to custom autoscaling policies. + + This class provides comprehensive information about a deployment's current state, + metrics, and configuration that can be used by custom autoscaling policies to + make intelligent scaling decisions. + + The context includes deployment metadata, current replica state, built-in and + custom metrics, capacity bounds, policy state, and timing information. + """ + + # Deployment information + deployment_id: DeploymentID #: Unique identifier for the deployment. + deployment_name: str #: Name of the deployment. + app_name: Optional[str] #: Name of the application containing this deployment. + + # Current state + current_num_replicas: int #: Current number of running replicas. + target_num_replicas: int #: Target number of replicas set by the autoscaler. + running_replicas: List[ReplicaID] #: List of currently running replica IDs. + + # Built-in metrics + total_num_requests: float #: Total number of requests across all replicas. + total_queued_requests: Optional[float] #: Number of requests currently queued. + total_running_requests: Optional[ + float + ] #: Total number of requests currently running. + + # Custom metrics + aggregated_metrics: Dict[ + str, Dict[ReplicaID, float] + ] #: Time-weighted averages of custom metrics per replica. + raw_metrics: Dict[ + str, Dict[ReplicaID, TimeSeries] + ] #: Raw custom metric timeseries per replica. + + # Capacity and bounds + capacity_adjusted_min_replicas: int #: Minimum replicas adjusted for cluster capacity. + capacity_adjusted_max_replicas: int #: Maximum replicas adjusted for cluster capacity. + + # Policy state + policy_state: Dict[ + str, Any + ] #: Persistent state dictionary for the autoscaling policy. + + # Timing + last_scale_up_time: Optional[float] #: Timestamp of last scale-up action. + last_scale_down_time: Optional[float] #: Timestamp of last scale-down action. + current_time: Optional[float] #: Current timestamp. + + # Config + config: Optional[Any] #: Autoscaling configuration for this deployment. + + +@PublicAPI(stability="alpha") +class RequestRouterConfig(BaseModel): + """Config for the Serve request router. + + This class configures how Ray Serve routes requests to deployment replicas. The router is + responsible for selecting which replica should handle each incoming request based on the + configured routing policy. You can customize the routing behavior by specifying a custom + request router class and providing configuration parameters. + + The router also manages periodic health checks and scheduling statistics collection from + replicas to make informed routing decisions. + + Example: + .. code-block:: python + + from ray.serve.config import RequestRouterConfig, DeploymentConfig + from ray import serve + + # Use default router with custom stats collection interval + request_router_config = RequestRouterConfig( + request_routing_stats_period_s=5.0, + request_routing_stats_timeout_s=15.0 + ) + + # Use custom router class + request_router_config = RequestRouterConfig( + request_router_class="ray.serve.llm.request_router.PrefixCacheAffinityRouter", + request_router_kwargs={"imbalanced_threshold": 20} + ) + deployment_config = DeploymentConfig( + request_router_config=request_router_config + ) + deployment = serve.deploy( + "my_deployment", + deployment_config=deployment_config + ) + """ + + _serialized_request_router_cls: bytes = PrivateAttr(default=b"") + + request_router_class: Union[str, Callable] = Field( + default=DEFAULT_REQUEST_ROUTER_PATH, + description=( + "The class of the request router that Ray Serve uses for this deployment. This value can be " + "a string or a class. All the deployment handles that you create for this " + "deployment use the routing policy defined by the request router. " + "Default to Serve's PowerOfTwoChoicesRequestRouter." + ), + ) + request_router_kwargs: Dict[str, Any] = Field( + default_factory=dict, + description=( + "Keyword arguments that Ray Serve passes to the request router class " + "initialize_state method." + ), + ) + + request_routing_stats_period_s: PositiveFloat = Field( + default=DEFAULT_REQUEST_ROUTING_STATS_PERIOD_S, + description=( + "Duration between record scheduling stats calls for the replica. " + "Defaults to 10s. The health check is by default a no-op Actor call " + "to the replica, but you can define your own request scheduling stats " + "using the 'record_scheduling_stats' method in your deployment." + ), + ) + + request_routing_stats_timeout_s: PositiveFloat = Field( + default=DEFAULT_REQUEST_ROUTING_STATS_TIMEOUT_S, + description=( + "Duration in seconds, that replicas wait for a request scheduling " + "stats method to return before considering it as failed. Defaults to 30s." + ), + ) + + @validator("request_router_kwargs", always=True) + def request_router_kwargs_json_serializable(cls, v): + if isinstance(v, bytes): + return v + if v is not None: + try: + json.dumps(v) + except TypeError as e: + raise ValueError( + f"request_router_kwargs is not JSON-serializable: {str(e)}." + ) + + return v + + def __init__(self, **kwargs: dict[str, Any]): + """Initialize RequestRouterConfig with the given parameters. + + Needed to serialize the request router class since validators are not called + for attributes that begin with an underscore. + + Args: + **kwargs: Keyword arguments to pass to BaseModel. + """ + serialized_request_router_cls = kwargs.pop( + "_serialized_request_router_cls", None + ) + super().__init__(**kwargs) + if serialized_request_router_cls: + self._serialized_request_router_cls = serialized_request_router_cls + else: + self._serialize_request_router_cls() + + def set_serialized_request_router_cls( + self, serialized_request_router_cls: bytes + ) -> None: + self._serialized_request_router_cls = serialized_request_router_cls + + @classmethod + def from_serialized_request_router_cls( + cls, request_router_config: dict, serialized_request_router_cls: bytes + ) -> "RequestRouterConfig": + config = request_router_config.copy() + config["_serialized_request_router_cls"] = serialized_request_router_cls + return cls(**config) + + def get_serialized_request_router_cls(self) -> Optional[bytes]: + return self._serialized_request_router_cls + + def _serialize_request_router_cls(self) -> None: + """Import and serialize request router class with cloudpickle. + + Import the request router if you pass it in as a string import path. + Then cloudpickle the request router and set to + `_serialized_request_router_cls`. + """ + request_router_class = self.request_router_class + if isinstance(request_router_class, Callable): + request_router_class = ( + f"{request_router_class.__module__}.{request_router_class.__name__}" + ) + + request_router_path = request_router_class or DEFAULT_REQUEST_ROUTER_PATH + request_router_module, request_router_class = import_module_and_attr( + request_router_path + ) + cloudpickle.register_pickle_by_value(request_router_module) + self.set_serialized_request_router_cls(cloudpickle.dumps(request_router_class)) + cloudpickle.unregister_pickle_by_value(request_router_module) + + # Update the request_router_class field to be the string path + self.request_router_class = request_router_path + + def get_request_router_class(self) -> Callable: + """Deserialize the request router from cloudpickled bytes.""" + try: + return cloudpickle.loads(self._serialized_request_router_cls) + except (ModuleNotFoundError, ImportError) as e: + raise ImportError( + f"Failed to deserialize custom request router: {e}\n\n" + "This typically happens when the router depends on external modules " + "that aren't available in the current environment. To fix this:\n" + " - Ensure all dependencies are installed in your Docker image or environment\n" + " - Package your router as a Python package and install it\n" + " - Place the router module in PYTHONPATH\n\n" + "For more details, see: https://docs.ray.io/en/latest/serve/advanced-guides/" + "custom-request-router.html#gotchas-and-limitations" + ) from e + + +DEFAULT_METRICS_INTERVAL_S = 10.0 + + +@PublicAPI(stability="alpha") +class AggregationFunction(str, Enum): + MEAN = "mean" + MAX = "max" + MIN = "min" + + +@PublicAPI(stability="alpha") +class AutoscalingPolicy(BaseModel): + # Cloudpickled policy definition. + _serialized_policy_def: bytes = PrivateAttr(default=b"") + + policy_function: Union[str, Callable] = Field( + default=DEFAULT_AUTOSCALING_POLICY_NAME, + description="Policy function can be a string import path or a function callable. " + "If it's a string import path, it must be of the form `path.to.module:function_name`. ", + ) + + def __init__(self, **kwargs): + serialized_policy_def = kwargs.pop("_serialized_policy_def", None) + super().__init__(**kwargs) + if serialized_policy_def: + self._serialized_policy_def = serialized_policy_def + else: + self.serialize_policy() + + def set_serialized_policy_def(self, serialized_policy_def: bytes) -> None: + self._serialized_policy_def = serialized_policy_def + + @classmethod + def from_serialized_policy_def( + cls, policy_config: dict, serialized_policy_def: bytes + ) -> "AutoscalingPolicy": + config = policy_config.copy() + config["_serialized_policy_def"] = serialized_policy_def + return cls(**config) + + def get_serialized_policy_def(self) -> Optional[bytes]: + return self._serialized_policy_def + + def serialize_policy(self) -> None: + """Serialize policy with cloudpickle. + + Import the policy if it's passed in as a string import path. Then cloudpickle + the policy and set `serialized_policy_def` if it's empty. + """ + policy_path = self.policy_function + + if isinstance(policy_path, Callable): + policy_path = f"{policy_path.__module__}.{policy_path.__name__}" + + if not self._serialized_policy_def: + policy_module, policy_function = import_module_and_attr(policy_path) + cloudpickle.register_pickle_by_value(policy_module) + self.set_serialized_policy_def(cloudpickle.dumps(policy_function)) + cloudpickle.unregister_pickle_by_value(policy_module) + + self.policy_function = policy_path + + def is_default_policy_function(self) -> bool: + return self.policy_function == DEFAULT_AUTOSCALING_POLICY_NAME + + def get_policy(self) -> Callable: + """Deserialize policy from cloudpickled bytes.""" + try: + return cloudpickle.loads(self._serialized_policy_def) + except (ModuleNotFoundError, ImportError) as e: + raise ImportError( + f"Failed to deserialize custom autoscaling policy: {e}\n\n" + "This typically happens when the policy depends on external modules " + "that aren't available in the current environment. To fix this:\n" + " - Ensure all dependencies are installed in your Docker image or environment\n" + " - Package your policy as a Python package and install it\n" + " - Place the policy module in PYTHONPATH\n\n" + "For more details, see: https://docs.ray.io/en/latest/serve/advanced-guides/" + "advanced-autoscaling.html#gotchas-and-limitations" + ) from e + + +@PublicAPI(stability="stable") +class AutoscalingConfig(BaseModel): + """Config for the Serve Autoscaler.""" + + # Please keep these options in sync with those in + # `src/ray/protobuf/serve.proto`. + + # Publicly exposed options + min_replicas: NonNegativeInt = 1 + initial_replicas: Optional[NonNegativeInt] = None + max_replicas: PositiveInt = 1 + + target_ongoing_requests: Optional[PositiveFloat] = DEFAULT_TARGET_ONGOING_REQUESTS + + metrics_interval_s: PositiveFloat = Field( + default=DEFAULT_METRICS_INTERVAL_S, + description="[DEPRECATED] How often to scrape for metrics. " + "Will be replaced by the environment variables " + "`RAY_SERVE_REPLICA_AUTOSCALING_METRIC_PUSH_INTERVAL_S` and " + "`RAY_SERVE_HANDLE_AUTOSCALING_METRIC_PUSH_INTERVAL_S` in a future release.", + ) + look_back_period_s: PositiveFloat = Field( + default=30.0, description="Time window to average over for metrics." + ) + + smoothing_factor: PositiveFloat = Field( + default=1.0, + description="[DEPRECATED] Smoothing factor for autoscaling decisions.", + ) + # DEPRECATED: replaced by `downscaling_factor` + upscale_smoothing_factor: Optional[PositiveFloat] = Field( + default=None, description="[DEPRECATED] Please use `upscaling_factor` instead." + ) + # DEPRECATED: replaced by `upscaling_factor` + downscale_smoothing_factor: Optional[PositiveFloat] = Field( + default=None, + description="[DEPRECATED] Please use `downscaling_factor` instead.", + ) + + upscaling_factor: Optional[PositiveFloat] = Field( + default=None, + description='Multiplicative "gain" factor to limit upscaling decisions.', + ) + downscaling_factor: Optional[PositiveFloat] = Field( + default=None, + description='Multiplicative "gain" factor to limit downscaling decisions.', + ) + + # How frequently to make autoscaling decisions + # loop_period_s: float = CONTROL_LOOP_PERIOD_S + downscale_delay_s: NonNegativeFloat = Field( + default=600.0, + description="How long to wait before scaling down replicas to a value greater than 0.", + ) + # Optionally set for 1->0 transition + downscale_to_zero_delay_s: Optional[NonNegativeFloat] = Field( + default=None, + description="How long to wait before scaling down replicas from 1 to 0. If not set, the value of `downscale_delay_s` will be used.", + ) + upscale_delay_s: NonNegativeFloat = Field( + default=30.0, description="How long to wait before scaling up replicas." + ) + + aggregation_function: Union[str, AggregationFunction] = Field( + default=AggregationFunction.MEAN, + description="Function used to aggregate metrics across a time window.", + ) + + # Autoscaling policy. This policy is deployment scoped. Defaults to the request-based autoscaler. + policy: AutoscalingPolicy = Field( + default_factory=AutoscalingPolicy, + description="The autoscaling policy for the deployment. This option is experimental.", + ) + + @validator("max_replicas", always=True) + def replicas_settings_valid(cls, max_replicas, values): + min_replicas = values.get("min_replicas") + initial_replicas = values.get("initial_replicas") + if min_replicas is not None and max_replicas < min_replicas: + raise ValueError( + f"max_replicas ({max_replicas}) must be greater than " + f"or equal to min_replicas ({min_replicas})!" + ) + + if initial_replicas is not None: + if initial_replicas < min_replicas: + raise ValueError( + f"min_replicas ({min_replicas}) must be less than " + f"or equal to initial_replicas ({initial_replicas})!" + ) + elif initial_replicas > max_replicas: + raise ValueError( + f"max_replicas ({max_replicas}) must be greater than " + f"or equal to initial_replicas ({initial_replicas})!" + ) + + return max_replicas + + @validator("metrics_interval_s") + def metrics_interval_s_deprecation_warning(cls, v: PositiveFloat) -> PositiveFloat: + if v != DEFAULT_METRICS_INTERVAL_S: + warnings.warn( + "The `metrics_interval_s` field in AutoscalingConfig is deprecated and " + "will be replaced by the environment variables " + "`RAY_SERVE_REPLICA_AUTOSCALING_METRIC_PUSH_INTERVAL_S` and " + "`RAY_SERVE_HANDLE_AUTOSCALING_METRIC_PUSH_INTERVAL_S` in a future release.", + DeprecationWarning, + ) + return v + + @validator("aggregation_function", always=True) + def aggregation_function_valid(cls, v: Union[str, AggregationFunction]): + if isinstance(v, AggregationFunction): + return v + return AggregationFunction(str(v).lower()) + + @classmethod + def default(cls): + return cls( + target_ongoing_requests=DEFAULT_TARGET_ONGOING_REQUESTS, + min_replicas=1, + max_replicas=100, + ) + + def get_upscaling_factor(self) -> PositiveFloat: + if self.upscaling_factor: + return self.upscaling_factor + + return self.upscale_smoothing_factor or self.smoothing_factor + + def get_downscaling_factor(self) -> PositiveFloat: + if self.downscaling_factor: + return self.downscaling_factor + + return self.downscale_smoothing_factor or self.smoothing_factor + + def get_target_ongoing_requests(self) -> PositiveFloat: + return self.target_ongoing_requests + + +# Keep in sync with ServeDeploymentMode in dashboard/client/src/type/serve.ts +@Deprecated +class DeploymentMode(str, Enum): + NoServer = "NoServer" + HeadOnly = "HeadOnly" + EveryNode = "EveryNode" + + +@PublicAPI(stability="stable") +class ProxyLocation(str, Enum): + """Config for where to run proxies to receive ingress traffic to the cluster. + + Options: + + - Disabled: don't run proxies at all. This should be used if you are only + making calls to your applications via deployment handles. + - HeadOnly: only run a single proxy on the head node. + - EveryNode: run a proxy on every node in the cluster that has at least one + replica actor. This is the default. + """ + + Disabled = "Disabled" + HeadOnly = "HeadOnly" + EveryNode = "EveryNode" + + @classmethod + def _to_deployment_mode( + cls, proxy_location: Union["ProxyLocation", str] + ) -> DeploymentMode: + if isinstance(proxy_location, str): + proxy_location = ProxyLocation(proxy_location) + elif not isinstance(proxy_location, ProxyLocation): + raise TypeError( + f"Must be a `ProxyLocation` or str, got: {type(proxy_location)}." + ) + + if proxy_location == ProxyLocation.Disabled: + return DeploymentMode.NoServer + else: + return DeploymentMode(proxy_location.value) + + @classmethod + def _from_deployment_mode( + cls, deployment_mode: Optional[Union[DeploymentMode, str]] + ) -> Optional["ProxyLocation"]: + """Converts DeploymentMode enum into ProxyLocation enum. + + DeploymentMode is a deprecated version of ProxyLocation that's still + used internally throughout Serve. + """ + + if deployment_mode is None: + return None + elif isinstance(deployment_mode, str): + deployment_mode = DeploymentMode(deployment_mode) + elif not isinstance(deployment_mode, DeploymentMode): + raise TypeError( + f"Must be a `DeploymentMode` or str, got: {type(deployment_mode)}." + ) + + if deployment_mode == DeploymentMode.NoServer: + return ProxyLocation.Disabled + else: + return ProxyLocation(deployment_mode.value) + + +@PublicAPI(stability="stable") +class HTTPOptions(BaseModel): + """HTTP options for the proxies. Supported fields: + + - host: Host that the proxies listens for HTTP on. Defaults to + "127.0.0.1". To expose Serve publicly, you probably want to set + this to "0.0.0.0". + - port: Port that the proxies listen for HTTP on. Defaults to 8000. + - root_path: An optional root path to mount the serve application + (for example, "/prefix"). All deployment routes are prefixed + with this path. + - request_timeout_s: End-to-end timeout for HTTP requests. + - keep_alive_timeout_s: Duration to keep idle connections alive when no + requests are ongoing. + - ssl_keyfile: Path to the SSL key file for HTTPS. If provided with + ssl_certfile, the HTTP server will use HTTPS. + - ssl_certfile: Path to the SSL certificate file for HTTPS. If provided + with ssl_keyfile, the HTTP server will use HTTPS. + - ssl_keyfile_password: Optional password for the SSL key file. + - ssl_ca_certs: Optional path to CA certificate file for client certificate + verification. + + - location: [DEPRECATED: use `proxy_location` field instead] The deployment + location of HTTP servers: + + - "HeadOnly": start one HTTP server on the head node. Serve + assumes the head node is the node you executed serve.start + on. This is the default. + - "EveryNode": start one HTTP server per node. + - "NoServer": disable HTTP server. + + - num_cpus: [DEPRECATED] The number of CPU cores to reserve for each + internal Serve HTTP proxy actor. + """ + + host: Optional[str] = DEFAULT_HTTP_HOST + port: int = DEFAULT_HTTP_PORT + middlewares: List[Any] = [] + location: Optional[DeploymentMode] = DeploymentMode.HeadOnly + num_cpus: int = 0 + root_url: str = "" + root_path: str = "" + request_timeout_s: Optional[float] = None + keep_alive_timeout_s: int = DEFAULT_UVICORN_KEEP_ALIVE_TIMEOUT_S + ssl_keyfile: Optional[str] = None + ssl_certfile: Optional[str] = None + ssl_keyfile_password: Optional[str] = None + ssl_ca_certs: Optional[str] = None + + @validator("location", always=True) + def location_backfill_no_server(cls, v, values): + if values["host"] is None or v is None: + return DeploymentMode.NoServer + + return v + + @validator("ssl_certfile") + def validate_ssl_certfile(cls, v, values): + ssl_keyfile = values.get("ssl_keyfile") + validate_ssl_config(v, ssl_keyfile) + return v + + @validator("middlewares", always=True) + def warn_for_middlewares(cls, v, values): + if v: + warnings.warn( + "Passing `middlewares` to HTTPOptions is deprecated and will be " + "removed in a future version. Consider using the FastAPI integration " + "to configure middlewares on your deployments: " + "https://docs.ray.io/en/latest/serve/http-guide.html#fastapi-http-deployments" # noqa 501 + ) + return v + + @validator("num_cpus", always=True) + def warn_for_num_cpus(cls, v, values): + if v: + warnings.warn( + "Passing `num_cpus` to HTTPOptions is deprecated and will be " + "removed in a future version." + ) + return v + + class Config: + validate_assignment = True + arbitrary_types_allowed = True + + +@PublicAPI(stability="alpha") +class gRPCOptions(BaseModel): + """gRPC options for the proxies. Supported fields: + + Args: + port (int): + Port for gRPC server if started. Default to 9000. Cannot be + updated once Serve has started running. Serve must be shut down and + restarted with the new port instead. + grpc_servicer_functions (List[str]): + List of import paths for gRPC `add_servicer_to_server` functions to add to + Serve's gRPC proxy. Default to empty list, which means no gRPC methods will + be added and no gRPC server will be started. The servicer functions need to + be importable from the context of where Serve is running. + request_timeout_s: End-to-end timeout for gRPC requests. + """ + + port: int = DEFAULT_GRPC_PORT + grpc_servicer_functions: List[str] = [] + request_timeout_s: Optional[float] = None + + @property + def grpc_servicer_func_callable(self) -> List[Callable]: + """Return a list of callable functions from the grpc_servicer_functions. + + If the function is not callable or not found, it will be ignored and a warning + will be logged. + """ + callables = [] + for func in self.grpc_servicer_functions: + try: + imported_func = import_attr(func) + if callable(imported_func): + callables.append(imported_func) + else: + message = ( + f"{func} is not a callable function! Please make sure " + "the function is imported correctly." + ) + raise ValueError(message) + except ModuleNotFoundError as e: + message = ( + f"{func} can't be imported! Please make sure there are no typo " + "in those functions. Or you might want to rebuild service " + "definitions if .proto file is changed." + ) + raise ModuleNotFoundError(message) from e + + return callables diff --git a/lib/python3.12/site-packages/ray/serve/context.py b/lib/python3.12/site-packages/ray/serve/context.py new file mode 100644 index 0000000000000000000000000000000000000000..1c9b92ad64ac98915066031e4170c36d33317cbc --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/context.py @@ -0,0 +1,331 @@ +""" +This file stores global state for a Serve application. Deployment replicas +can use this state to access metadata or the Serve controller. +""" + +import asyncio +import contextvars +import logging +from collections import defaultdict +from dataclasses import dataclass +from typing import Callable, Dict, List, Optional + +import ray +from ray.exceptions import RayActorError +from ray.serve._private.client import ServeControllerClient +from ray.serve._private.common import DeploymentID, ReplicaID +from ray.serve._private.config import DeploymentConfig +from ray.serve._private.constants import ( + SERVE_CONTROLLER_NAME, + SERVE_LOGGER_NAME, + SERVE_NAMESPACE, +) +from ray.serve._private.replica_result import ReplicaResult +from ray.serve.exceptions import RayServeException +from ray.serve.grpc_util import RayServegRPCContext +from ray.serve.schema import ReplicaRank +from ray.util.annotations import DeveloperAPI + +logger = logging.getLogger(SERVE_LOGGER_NAME) + +_INTERNAL_REPLICA_CONTEXT: "ReplicaContext" = None +_global_client: ServeControllerClient = None + + +@DeveloperAPI +@dataclass +class ReplicaContext: + """Stores runtime context info for replicas. + + Fields: + - app_name: name of the application the replica is a part of. + - deployment: name of the deployment the replica is a part of. + - replica_tag: unique ID for the replica. + - servable_object: instance of the user class/function this replica is running. + - rank: the rank of the replica. + - world_size: the number of replicas in the deployment. + """ + + replica_id: ReplicaID + servable_object: Callable + _deployment_config: DeploymentConfig + rank: ReplicaRank + world_size: int + _handle_registration_callback: Optional[Callable[[DeploymentID], None]] = None + + @property + def app_name(self) -> str: + return self.replica_id.deployment_id.app_name + + @property + def deployment(self) -> str: + return self.replica_id.deployment_id.name + + @property + def replica_tag(self) -> str: + return self.replica_id.unique_id + + +def _get_global_client( + _health_check_controller: bool = False, raise_if_no_controller_running: bool = True +) -> Optional[ServeControllerClient]: + """Gets the global client, which stores the controller's handle. + + Args: + _health_check_controller: If True, run a health check on the + cached controller if it exists. If the check fails, try reconnecting + to the controller. + raise_if_no_controller_running: Whether to raise an exception if + there is no currently running Serve controller. + + Returns: + ServeControllerClient to the running Serve controller. If there + is no running controller and raise_if_no_controller_running is + set to False, returns None. + + Raises: + RayServeException: If there is no running Serve controller actor + and raise_if_no_controller_running is set to True. + """ + + try: + if _global_client is not None: + if _health_check_controller: + ray.get(_global_client._controller.check_alive.remote()) + return _global_client + except RayActorError: + logger.info("The cached controller has died. Reconnecting.") + _set_global_client(None) + + return _connect(raise_if_no_controller_running) + + +def _set_global_client(client): + global _global_client + _global_client = client + + +def _get_internal_replica_context(): + return _INTERNAL_REPLICA_CONTEXT + + +def _set_internal_replica_context( + *, + replica_id: ReplicaID, + servable_object: Callable, + _deployment_config: DeploymentConfig, + rank: ReplicaRank, + world_size: int, + handle_registration_callback: Optional[Callable[[str, str], None]] = None, +): + global _INTERNAL_REPLICA_CONTEXT + _INTERNAL_REPLICA_CONTEXT = ReplicaContext( + replica_id=replica_id, + servable_object=servable_object, + _deployment_config=_deployment_config, + rank=rank, + world_size=world_size, + _handle_registration_callback=handle_registration_callback, + ) + + +def _connect(raise_if_no_controller_running: bool = True) -> ServeControllerClient: + """Connect to an existing Serve application on this Ray cluster. + + If called from within a replica, this will connect to the same Serve + app that the replica is running in. + + Returns: + ServeControllerClient that encapsulates a Ray actor handle to the + existing Serve application's Serve Controller. None if there is + no running Serve controller actor and raise_if_no_controller_running + is set to False. + + Raises: + RayServeException: If there is no running Serve controller actor + and raise_if_no_controller_running is set to True. + """ + + # Initialize ray if needed. + ray._private.worker.global_worker._filter_logs_by_job = False + if not ray.is_initialized(): + ray.init(namespace=SERVE_NAMESPACE) + + # Try to get serve controller if it exists + try: + controller = ray.get_actor(SERVE_CONTROLLER_NAME, namespace=SERVE_NAMESPACE) + except ValueError: + if raise_if_no_controller_running: + raise RayServeException( + "There is no Serve instance running on this Ray cluster." + ) + return + + client = ServeControllerClient( + controller, + ) + _set_global_client(client) + return client + + +# Serve request context var which is used for storing the internal +# request context information. +# route_prefix: http url route path, e.g. http://127.0.0.1:/app +# the route is "/app". When you send requests by handle, +# the route is empty. +# request_id: the request id is generated from http proxy, the value +# shouldn't be changed when the variable is set. +# This can be from the client and is used for logging. +# _internal_request_id: the request id is generated from the proxy. Used to track the +# request objects in the system. +# note: +# The request context is readonly to avoid potential +# async task conflicts when using it concurrently. + + +@dataclass(frozen=True) +class _RequestContext: + route: str = "" + request_id: str = "" + _internal_request_id: str = "" + app_name: str = "" + multiplexed_model_id: str = "" + grpc_context: Optional[RayServegRPCContext] = None + is_http_request: bool = False + cancel_on_parent_request_cancel: bool = False + + +_serve_request_context = contextvars.ContextVar( + "Serve internal request context variable", default=None +) + +_serve_batch_request_context = contextvars.ContextVar( + "Serve internal batching request context variable", default=None +) + + +def _get_serve_request_context(): + """Get the current request context. + + Returns: + The current request context + """ + + if _serve_request_context.get() is None: + _serve_request_context.set(_RequestContext()) + return _serve_request_context.get() + + +def _get_serve_batch_request_context(): + """Get the list of request contexts for the current batch.""" + if _serve_batch_request_context.get() is None: + _serve_batch_request_context.set([]) + return _serve_batch_request_context.get() + + +def _set_request_context( + route: str = "", + request_id: str = "", + _internal_request_id: str = "", + app_name: str = "", + multiplexed_model_id: str = "", +): + """Set the request context. If the value is not set, + the current context value will be used.""" + + current_request_context = _get_serve_request_context() + + _serve_request_context.set( + _RequestContext( + route=route or current_request_context.route, + request_id=request_id or current_request_context.request_id, + _internal_request_id=_internal_request_id + or current_request_context._internal_request_id, + app_name=app_name or current_request_context.app_name, + multiplexed_model_id=multiplexed_model_id + or current_request_context.multiplexed_model_id, + ) + ) + + +def _unset_request_context(): + """Unset the request context.""" + _serve_request_context.set(_RequestContext()) + + +def _set_batch_request_context(request_contexts: List[_RequestContext]): + """Add the request context to the batch request context.""" + _serve_batch_request_context.set(request_contexts) + + +# `_requests_pending_assignment` is a map from request ID to a +# dictionary of asyncio tasks. +# The request ID points to an ongoing request that is executing on the +# current replica, and the asyncio tasks are ongoing tasks started on +# the router to assign child requests to downstream replicas. + +# A dictionary is used over a set to track the asyncio tasks for more +# efficient addition and deletion time complexity. A uniquely generated +# `response_id` is used to identify each task. + +_requests_pending_assignment: Dict[str, Dict[str, asyncio.Task]] = defaultdict(dict) + + +# Note that the functions below that manipulate +# `_requests_pending_assignment` are NOT thread-safe. They are only +# expected to be called from the same thread/asyncio event-loop. + + +def _get_requests_pending_assignment(parent_request_id: str) -> Dict[str, asyncio.Task]: + if parent_request_id in _requests_pending_assignment: + return _requests_pending_assignment[parent_request_id] + + return {} + + +def _add_request_pending_assignment(parent_request_id: str, response_id: str, task): + # NOTE: `parent_request_id` is the `internal_request_id` corresponding + # to an ongoing Serve request, so it is always non-empty. + _requests_pending_assignment[parent_request_id][response_id] = task + + +def _remove_request_pending_assignment(parent_request_id: str, response_id: str): + if response_id in _requests_pending_assignment[parent_request_id]: + del _requests_pending_assignment[parent_request_id][response_id] + + if len(_requests_pending_assignment[parent_request_id]) == 0: + del _requests_pending_assignment[parent_request_id] + + +# `_in_flight_requests` is a map from request ID to a dictionary of replica results. +# The request ID points to an ongoing Serve request, and the replica results are +# in-flight child requests that have been assigned to a downstream replica. + +# A dictionary is used over a set to track the replica results for more +# efficient addition and deletion time complexity. A uniquely generated +# `response_id` is used to identify each replica result. + +_in_flight_requests: Dict[str, Dict[str, ReplicaResult]] = defaultdict(dict) + +# Note that the functions below that manipulate `_in_flight_requests` +# are NOT thread-safe. They are only expected to be called from the +# same thread/asyncio event-loop. + + +def _get_in_flight_requests(parent_request_id): + if parent_request_id in _in_flight_requests: + return _in_flight_requests[parent_request_id] + + return {} + + +def _add_in_flight_request(parent_request_id, response_id, replica_result): + _in_flight_requests[parent_request_id][response_id] = replica_result + + +def _remove_in_flight_request(parent_request_id, response_id): + if response_id in _in_flight_requests[parent_request_id]: + del _in_flight_requests[parent_request_id][response_id] + + if len(_in_flight_requests[parent_request_id]) == 0: + del _in_flight_requests[parent_request_id] diff --git a/lib/python3.12/site-packages/ray/serve/dag.py b/lib/python3.12/site-packages/ray/serve/dag.py new file mode 100644 index 0000000000000000000000000000000000000000..aee604744557d18c88245fff723480562dbfae67 --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/dag.py @@ -0,0 +1,5 @@ +from ray.dag.input_node import InputNode + +__all__ = [ + "InputNode", +] diff --git a/lib/python3.12/site-packages/ray/serve/deployment.py b/lib/python3.12/site-packages/ray/serve/deployment.py new file mode 100644 index 0000000000000000000000000000000000000000..31505c8ef70de5e3febd696fb170dd37b58b552e --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/deployment.py @@ -0,0 +1,524 @@ +import logging +import warnings +from copy import deepcopy +from typing import Any, Callable, Dict, List, Optional, Tuple, Union + +from ray.serve._private.config import ( + DeploymentConfig, + ReplicaConfig, + RequestRouterConfig, + handle_num_replicas_auto, +) +from ray.serve._private.constants import SERVE_LOGGER_NAME +from ray.serve._private.usage import ServeUsageTag +from ray.serve._private.utils import DEFAULT, Default +from ray.serve.config import AutoscalingConfig +from ray.serve.schema import DeploymentSchema, LoggingConfig, RayActorOptionsSchema +from ray.util.annotations import PublicAPI + +logger = logging.getLogger(SERVE_LOGGER_NAME) + + +@PublicAPI(stability="stable") +class Application: + """One or more deployments bound with arguments that can be deployed together. + + Can be passed into another `Deployment.bind()` to compose multiple deployments in a + single application, passed to `serve.run`, or deployed via a Serve config file. + + For example, to define an Application and run it in Python: + + .. code-block:: python + + from ray import serve + from ray.serve import Application + + @serve.deployment + class MyDeployment: + pass + + app: Application = MyDeployment.bind(OtherDeployment.bind()) + serve.run(app) + + To run the same app using the command line interface (CLI): + + .. code-block:: bash + + serve run python_file:app + + To deploy the same app via a config file: + + .. code-block:: yaml + + applications: + my_app: + import_path: python_file:app + + """ + + def __init__(self, bound_deployment: "Deployment"): + # This is used by `build_app`, but made private so users don't use it. + self._bound_deployment = bound_deployment + + +@PublicAPI(stability="stable") +class Deployment: + """Class (or function) decorated with the `@serve.deployment` decorator. + + This is run on a number of replica actors. Requests to those replicas call + this class. + + One or more deployments can be composed together into an `Application` which is + then run via `serve.run` or a config file. + + Example: + + .. code-block:: python + + @serve.deployment + class MyDeployment: + def __init__(self, name: str): + self._name = name + + def __call__(self, request): + return "Hello world!" + + app = MyDeployment.bind() + # Run via `serve.run` or the `serve run` CLI command. + serve.run(app) + + """ + + def __init__( + self, + name: str, + deployment_config: DeploymentConfig, + replica_config: ReplicaConfig, + version: Optional[str] = None, + _internal=False, + ) -> None: + if not _internal: + raise RuntimeError( + "The Deployment constructor should not be called " + "directly. Use `@serve.deployment` instead." + ) + self._validate_name(name) + if not (version is None or isinstance(version, str)): + raise TypeError("version must be a string.") + + self._name = name + self._version = version + self._deployment_config = deployment_config + self._replica_config = replica_config + + def _validate_name(self, name: str): + if not isinstance(name, str): + raise TypeError("name must be a string.") + + # name does not contain # + if "#" in name: + warnings.warn( + f"Deployment names should not contain the '#' character, this will raise an error starting in Ray 2.46.0. " + f"Current name: {name}." + ) + + @property + def name(self) -> str: + """Unique name of this deployment.""" + return self._name + + @property + def version(self) -> Optional[str]: + return self._version + + @property + def func_or_class(self) -> Union[Callable, str]: + """Underlying class or function that this deployment wraps.""" + return self._replica_config.deployment_def + + @property + def num_replicas(self) -> int: + """Target number of replicas.""" + return self._deployment_config.num_replicas + + @property + def user_config(self) -> Any: + """Dynamic user-provided config options.""" + return self._deployment_config.user_config + + @property + def max_ongoing_requests(self) -> int: + """Max number of requests a replica can handle at once.""" + return self._deployment_config.max_ongoing_requests + + @property + def max_queued_requests(self) -> int: + """Max number of requests that can be queued in each deployment handle.""" + return self._deployment_config.max_queued_requests + + @property + def route_prefix(self): + raise ValueError( + "`route_prefix` can no longer be specified at the deployment level. " + "Pass it to `serve.run` or in the application config instead." + ) + + @property + def ray_actor_options(self) -> Optional[Dict]: + """Actor options such as resources required for each replica.""" + return self._replica_config.ray_actor_options + + @property + def init_args(self) -> Tuple[Any]: + return self._replica_config.init_args + + @property + def init_kwargs(self) -> Tuple[Any]: + return self._replica_config.init_kwargs + + @property + def url(self) -> Optional[str]: + logger.warning( + "DeprecationWarning: `Deployment.url` is deprecated " + "and will be removed in the future." + ) + return None + + @property + def logging_config(self) -> Dict: + return self._deployment_config.logging_config + + def set_logging_config(self, logging_config: Dict): + self._deployment_config.logging_config = logging_config + + def __call__(self): + raise RuntimeError( + "Deployments cannot be constructed directly. " + "Use `deployment.deploy() instead.`" + ) + + def bind(self, *args, **kwargs) -> Application: + """Bind the arguments to the deployment and return an Application. + + The returned Application can be deployed using `serve.run` (or via + config file) or bound to another deployment for composition. + """ + return Application(self.options(_init_args=args, _init_kwargs=kwargs)) + + def options( + self, + func_or_class: Optional[Callable] = None, + name: Default[str] = DEFAULT.VALUE, + version: Default[str] = DEFAULT.VALUE, + num_replicas: Default[Optional[Union[int, str]]] = DEFAULT.VALUE, + route_prefix: Default[Union[str, None]] = DEFAULT.VALUE, + ray_actor_options: Default[Optional[Dict]] = DEFAULT.VALUE, + placement_group_bundles: Default[List[Dict[str, float]]] = DEFAULT.VALUE, + placement_group_strategy: Default[str] = DEFAULT.VALUE, + max_replicas_per_node: Default[int] = DEFAULT.VALUE, + user_config: Default[Optional[Any]] = DEFAULT.VALUE, + max_ongoing_requests: Default[int] = DEFAULT.VALUE, + max_queued_requests: Default[int] = DEFAULT.VALUE, + autoscaling_config: Default[ + Union[Dict, AutoscalingConfig, None] + ] = DEFAULT.VALUE, + graceful_shutdown_wait_loop_s: Default[float] = DEFAULT.VALUE, + graceful_shutdown_timeout_s: Default[float] = DEFAULT.VALUE, + health_check_period_s: Default[float] = DEFAULT.VALUE, + health_check_timeout_s: Default[float] = DEFAULT.VALUE, + logging_config: Default[Union[Dict, LoggingConfig, None]] = DEFAULT.VALUE, + request_router_config: Default[ + Union[Dict, RequestRouterConfig, None] + ] = DEFAULT.VALUE, + _init_args: Default[Tuple[Any]] = DEFAULT.VALUE, + _init_kwargs: Default[Dict[Any, Any]] = DEFAULT.VALUE, + _internal: bool = False, + max_constructor_retry_count: Default[int] = DEFAULT.VALUE, + ) -> "Deployment": + """Return a copy of this deployment with updated options. + + Only those options passed in will be updated, all others will remain + unchanged from the existing deployment. + + Refer to the `@serve.deployment` decorator docs for available arguments. + """ + if route_prefix is not DEFAULT.VALUE: + raise ValueError( + "`route_prefix` can no longer be specified at the deployment level. " + "Pass it to `serve.run` or in the application config instead." + ) + + # Modify max_ongoing_requests and autoscaling_config if + # `num_replicas="auto"` + if max_ongoing_requests is None: + raise ValueError("`max_ongoing_requests` must be non-null, got None.") + if num_replicas == "auto": + num_replicas = None + max_ongoing_requests, autoscaling_config = handle_num_replicas_auto( + max_ongoing_requests, autoscaling_config + ) + + ServeUsageTag.AUTO_NUM_REPLICAS_USED.record("1") + + # NOTE: The user_configured_option_names should be the first thing that's + # defined in this method. It depends on the locals() dictionary storing + # only the function args/kwargs. + # Create list of all user-configured options from keyword args + user_configured_option_names = [ + option + for option, value in locals().items() + if option not in {"self", "func_or_class", "_internal"} + and value is not DEFAULT.VALUE + ] + + new_deployment_config = deepcopy(self._deployment_config) + if not _internal: + new_deployment_config.user_configured_option_names.update( + user_configured_option_names + ) + + if num_replicas not in [ + DEFAULT.VALUE, + None, + "auto", + ] and autoscaling_config not in [ + DEFAULT.VALUE, + None, + ]: + raise ValueError( + "Manually setting num_replicas is not allowed when " + "autoscaling_config is provided." + ) + + if num_replicas == 0: + raise ValueError("num_replicas is expected to larger than 0") + + if not _internal and version is not DEFAULT.VALUE: + logger.warning( + "DeprecationWarning: `version` in `Deployment.options()` has been " + "deprecated. Explicitly specifying version will raise an error in the " + "future!" + ) + + elif num_replicas not in [DEFAULT.VALUE, None]: + new_deployment_config.num_replicas = num_replicas + + if user_config is not DEFAULT.VALUE: + new_deployment_config.user_config = user_config + + if max_ongoing_requests is not DEFAULT.VALUE: + new_deployment_config.max_ongoing_requests = max_ongoing_requests + + if max_queued_requests is not DEFAULT.VALUE: + new_deployment_config.max_queued_requests = max_queued_requests + + if max_constructor_retry_count is not DEFAULT.VALUE: + new_deployment_config.max_constructor_retry_count = ( + max_constructor_retry_count + ) + + if func_or_class is None: + func_or_class = self._replica_config.deployment_def + + if name is DEFAULT.VALUE: + name = self._name + + if version is DEFAULT.VALUE: + version = self._version + + if _init_args is DEFAULT.VALUE: + _init_args = self._replica_config.init_args + + if _init_kwargs is DEFAULT.VALUE: + _init_kwargs = self._replica_config.init_kwargs + + if ray_actor_options is DEFAULT.VALUE: + ray_actor_options = self._replica_config.ray_actor_options + + if placement_group_bundles is DEFAULT.VALUE: + placement_group_bundles = self._replica_config.placement_group_bundles + + if placement_group_strategy is DEFAULT.VALUE: + placement_group_strategy = self._replica_config.placement_group_strategy + + if max_replicas_per_node is DEFAULT.VALUE: + max_replicas_per_node = self._replica_config.max_replicas_per_node + + if autoscaling_config is not DEFAULT.VALUE: + new_deployment_config.autoscaling_config = autoscaling_config + + if request_router_config is not DEFAULT.VALUE: + new_deployment_config.request_router_config = request_router_config + + if graceful_shutdown_wait_loop_s is not DEFAULT.VALUE: + new_deployment_config.graceful_shutdown_wait_loop_s = ( + graceful_shutdown_wait_loop_s + ) + + if graceful_shutdown_timeout_s is not DEFAULT.VALUE: + new_deployment_config.graceful_shutdown_timeout_s = ( + graceful_shutdown_timeout_s + ) + + if health_check_period_s is not DEFAULT.VALUE: + new_deployment_config.health_check_period_s = health_check_period_s + + if health_check_timeout_s is not DEFAULT.VALUE: + new_deployment_config.health_check_timeout_s = health_check_timeout_s + + if logging_config is not DEFAULT.VALUE: + if isinstance(logging_config, LoggingConfig): + logging_config = logging_config.dict() + new_deployment_config.logging_config = logging_config + + new_replica_config = ReplicaConfig.create( + func_or_class, + init_args=_init_args, + init_kwargs=_init_kwargs, + ray_actor_options=ray_actor_options, + placement_group_bundles=placement_group_bundles, + placement_group_strategy=placement_group_strategy, + max_replicas_per_node=max_replicas_per_node, + ) + + return Deployment( + name, + new_deployment_config, + new_replica_config, + version=version, + _internal=True, + ) + + def __eq__(self, other): + return all( + [ + self._name == other._name, + self._version == other._version, + self._deployment_config == other._deployment_config, + self._replica_config.init_args == other._replica_config.init_args, + self._replica_config.init_kwargs == other._replica_config.init_kwargs, + self._replica_config.ray_actor_options + == other._replica_config.ray_actor_options, + ] + ) + + def __str__(self): + return f"Deployment(name={self._name})" + + def __repr__(self): + return str(self) + + +def deployment_to_schema(d: Deployment) -> DeploymentSchema: + """Converts a live deployment object to a corresponding structured schema. + + Args: + d: Deployment object to convert + """ + + if d.ray_actor_options is not None: + ray_actor_options_schema = RayActorOptionsSchema.parse_obj(d.ray_actor_options) + else: + ray_actor_options_schema = None + + deployment_options = { + "name": d.name, + "num_replicas": None + if d._deployment_config.autoscaling_config + else d.num_replicas, + "max_ongoing_requests": d.max_ongoing_requests, + "max_queued_requests": d.max_queued_requests, + "user_config": d.user_config, + "autoscaling_config": d._deployment_config.autoscaling_config, + "graceful_shutdown_wait_loop_s": d._deployment_config.graceful_shutdown_wait_loop_s, # noqa: E501 + "graceful_shutdown_timeout_s": d._deployment_config.graceful_shutdown_timeout_s, + "health_check_period_s": d._deployment_config.health_check_period_s, + "health_check_timeout_s": d._deployment_config.health_check_timeout_s, + "ray_actor_options": ray_actor_options_schema, + "placement_group_strategy": d._replica_config.placement_group_strategy, + "placement_group_bundles": d._replica_config.placement_group_bundles, + "max_replicas_per_node": d._replica_config.max_replicas_per_node, + "logging_config": d._deployment_config.logging_config, + "request_router_config": d._deployment_config.request_router_config, + } + + # Let non-user-configured options be set to defaults. If the schema + # is converted back to a deployment, this lets Serve continue tracking + # which options were set by the user. Name is a required field in the + # schema, so it should be passed in explicitly. + for option in list(deployment_options.keys()): + if ( + option != "name" + and option not in d._deployment_config.user_configured_option_names + ): + del deployment_options[option] + + # TODO(Sihan) DeploymentConfig num_replicas and auto_config can be set together + # because internally we use these two field for autoscale and deploy. + # We can improve the code after we separate the user faced deployment config and + # internal deployment config. + return DeploymentSchema(**deployment_options) + + +def schema_to_deployment(s: DeploymentSchema) -> Deployment: + """Creates a deployment with parameters specified in schema. + + The returned deployment CANNOT be deployed immediately. It's func_or_class + value is an empty string (""), which is not a valid import path. The + func_or_class value must be overwritten with a valid function or class + before the deployment can be deployed. + """ + + if s.ray_actor_options is DEFAULT.VALUE: + ray_actor_options = None + else: + ray_actor_options = s.ray_actor_options.dict(exclude_unset=True) + + if s.placement_group_bundles is DEFAULT.VALUE: + placement_group_bundles = None + else: + placement_group_bundles = s.placement_group_bundles + + if s.placement_group_strategy is DEFAULT.VALUE: + placement_group_strategy = None + else: + placement_group_strategy = s.placement_group_strategy + + if s.max_replicas_per_node is DEFAULT.VALUE: + max_replicas_per_node = None + else: + max_replicas_per_node = s.max_replicas_per_node + + deployment_config = DeploymentConfig.from_default( + num_replicas=s.num_replicas, + user_config=s.user_config, + max_ongoing_requests=s.max_ongoing_requests, + max_queued_requests=s.max_queued_requests, + autoscaling_config=s.autoscaling_config, + graceful_shutdown_wait_loop_s=s.graceful_shutdown_wait_loop_s, + graceful_shutdown_timeout_s=s.graceful_shutdown_timeout_s, + health_check_period_s=s.health_check_period_s, + health_check_timeout_s=s.health_check_timeout_s, + logging_config=s.logging_config, + request_router_config=s.request_router_config, + ) + deployment_config.user_configured_option_names = ( + s._get_user_configured_option_names() + ) + + replica_config = ReplicaConfig.create( + deployment_def="", + init_args=(), + init_kwargs={}, + ray_actor_options=ray_actor_options, + placement_group_bundles=placement_group_bundles, + placement_group_strategy=placement_group_strategy, + max_replicas_per_node=max_replicas_per_node, + ) + + return Deployment( + name=s.name, + deployment_config=deployment_config, + replica_config=replica_config, + _internal=True, + ) diff --git a/lib/python3.12/site-packages/ray/serve/exceptions.py b/lib/python3.12/site-packages/ray/serve/exceptions.py new file mode 100644 index 0000000000000000000000000000000000000000..10033b28f9cd366fbe27fc78383a46340d71fb34 --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/exceptions.py @@ -0,0 +1,59 @@ +from typing import Optional + +from ray.exceptions import TaskCancelledError +from ray.serve._private.common import DeploymentID +from ray.util.annotations import PublicAPI + + +@PublicAPI(stability="stable") +class RayServeException(Exception): + pass + + +@PublicAPI(stability="alpha") +class BackPressureError(RayServeException): + """Raised when max_queued_requests is exceeded on a DeploymentHandle.""" + + def __init__(self, num_queued_requests: int, max_queued_requests: int): + super().__init__(num_queued_requests, max_queued_requests) + self._message = ( + f"Request dropped due to backpressure " + f"(num_queued_requests={num_queued_requests}, " + f"max_queued_requests={max_queued_requests})." + ) + + def __str__(self) -> str: + return self._message + + @property + def message(self) -> str: + return self._message + + +@PublicAPI(stability="alpha") +class RequestCancelledError(RayServeException, TaskCancelledError): + """Raise when a Serve request is cancelled.""" + + def __init__(self, request_id: Optional[str] = None): + self._request_id: Optional[str] = request_id + + def __str__(self): + if self._request_id: + return f"Request {self._request_id} was cancelled." + else: + return "Request was cancelled." + + +@PublicAPI(stability="alpha") +class DeploymentUnavailableError(RayServeException): + """Raised when a Serve deployment is unavailable to receive requests. + + Currently this happens because the deployment failed to deploy. + """ + + def __init__(self, deployment_id: DeploymentID): + self._deployment_id = deployment_id + + @property + def message(self) -> str: + return f"{self._deployment_id} is unavailable because it failed to deploy." diff --git a/lib/python3.12/site-packages/ray/serve/generated/__pycache__/serve_pb2.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/generated/__pycache__/serve_pb2.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7785a6414189f1c2e6048decda8a9b4292916bac Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/generated/__pycache__/serve_pb2.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/generated/__pycache__/serve_pb2_grpc.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/generated/__pycache__/serve_pb2_grpc.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5a4ee3a8f3a09f86305f56de3e6360d66fb713ab Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/generated/__pycache__/serve_pb2_grpc.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/generated/serve_pb2.py b/lib/python3.12/site-packages/ray/serve/generated/serve_pb2.py new file mode 100644 index 0000000000000000000000000000000000000000..baac9e98005fa55efd24b501b282ec2ab71793f0 --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/generated/serve_pb2.py @@ -0,0 +1,526 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: src/ray/protobuf/serve.proto +"""Generated protocol buffer code.""" +from google.protobuf.internal import enum_type_wrapper +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import message as _message +from google.protobuf import reflection as _reflection +from google.protobuf import symbol_database as _symbol_database +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1csrc/ray/protobuf/serve.proto\x12\tray.serve\"q\n\x11\x41utoscalingPolicy\x12\'\n\x0fpolicy_function\x18\x01 \x01(\tR\x0epolicyFunction\x12\x33\n\x16_serialized_policy_def\x18\x02 \x01(\x0cR\x13SerializedPolicyDef\"\xc4\x07\n\x11\x41utoscalingConfig\x12!\n\x0cmin_replicas\x18\x01 \x01(\rR\x0bminReplicas\x12!\n\x0cmax_replicas\x18\x02 \x01(\rR\x0bmaxReplicas\x12,\n\x12metrics_interval_s\x18\x03 \x01(\x01R\x10metricsIntervalS\x12+\n\x12look_back_period_s\x18\x04 \x01(\x01R\x0flookBackPeriodS\x12)\n\x10smoothing_factor\x18\x05 \x01(\x01R\x0fsmoothingFactor\x12*\n\x11\x64ownscale_delay_s\x18\x06 \x01(\x01R\x0f\x64ownscaleDelayS\x12&\n\x0fupscale_delay_s\x18\x07 \x01(\x01R\rupscaleDelayS\x12.\n\x10initial_replicas\x18\x08 \x01(\rH\x00R\x0finitialReplicas\x88\x01\x01\x12=\n\x18upscale_smoothing_factor\x18\t \x01(\x01H\x01R\x16upscaleSmoothingFactor\x88\x01\x01\x12\x41\n\x1a\x64ownscale_smoothing_factor\x18\n \x01(\x01H\x02R\x18\x64ownscaleSmoothingFactor\x88\x01\x01\x12\x34\n\x06policy\x18\x0b \x01(\x0b\x32\x1c.ray.serve.AutoscalingPolicyR\x06policy\x12\x36\n\x17target_ongoing_requests\x18\x0c \x01(\x01R\x15targetOngoingRequests\x12.\n\x10upscaling_factor\x18\r \x01(\x01H\x03R\x0fupscalingFactor\x88\x01\x01\x12\x32\n\x12\x64ownscaling_factor\x18\x0e \x01(\x01H\x04R\x11\x64ownscalingFactor\x88\x01\x01\x12=\n\x19\x64ownscale_to_zero_delay_s\x18\x0f \x01(\x01H\x05R\x15\x64ownscaleToZeroDelayS\x88\x01\x01\x12\x31\n\x14\x61ggregation_function\x18\x10 \x01(\tR\x13\x61ggregationFunctionB\x13\n\x11_initial_replicasB\x1b\n\x19_upscale_smoothing_factorB\x1d\n\x1b_downscale_smoothing_factorB\x13\n\x11_upscaling_factorB\x15\n\x13_downscaling_factorB\x1c\n\x1a_downscale_to_zero_delay_s\"\xeb\x01\n\rLoggingConfig\x12\x33\n\x08\x65ncoding\x18\x01 \x01(\x0e\x32\x17.ray.serve.EncodingTypeR\x08\x65ncoding\x12\x1b\n\tlog_level\x18\x02 \x01(\tR\x08logLevel\x12\x19\n\x08logs_dir\x18\x03 \x01(\tR\x07logsDir\x12*\n\x11\x65nable_access_log\x18\x04 \x01(\x08R\x0f\x65nableAccessLog\x12\x41\n\x1d\x61\x64\x64itional_log_standard_attrs\x18\x05 \x03(\tR\x1a\x61\x64\x64itionalLogStandardAttrs\"\xc9\x02\n\x13RequestRouterConfig\x12\x42\n\x1e_serialized_request_router_cls\x18\x01 \x01(\x0cR\x1aSerializedRequestRouterCls\x12\x30\n\x14request_router_class\x18\x02 \x01(\tR\x12requestRouterClass\x12\x42\n\x1erequest_routing_stats_period_s\x18\x03 \x01(\x01R\x1arequestRoutingStatsPeriodS\x12\x44\n\x1frequest_routing_stats_timeout_s\x18\x04 \x01(\x01R\x1brequestRoutingStatsTimeoutS\x12\x32\n\x15request_router_kwargs\x18\x05 \x01(\x0cR\x13requestRouterKwargs\"\x99\x07\n\x10\x44\x65ploymentConfig\x12!\n\x0cnum_replicas\x18\x01 \x01(\x05R\x0bnumReplicas\x12\x30\n\x14max_ongoing_requests\x18\x02 \x01(\x05R\x12maxOngoingRequests\x12.\n\x13max_queued_requests\x18\x03 \x01(\x05R\x11maxQueuedRequests\x12\x1f\n\x0buser_config\x18\x04 \x01(\x0cR\nuserConfig\x12@\n\x1dgraceful_shutdown_wait_loop_s\x18\x05 \x01(\x01R\x19gracefulShutdownWaitLoopS\x12=\n\x1bgraceful_shutdown_timeout_s\x18\x06 \x01(\x01R\x18gracefulShutdownTimeoutS\x12\x31\n\x15health_check_period_s\x18\x07 \x01(\x01R\x12healthCheckPeriodS\x12\x33\n\x16health_check_timeout_s\x18\x08 \x01(\x01R\x13healthCheckTimeoutS\x12*\n\x11is_cross_language\x18\t \x01(\x08R\x0fisCrossLanguage\x12N\n\x13\x64\x65ployment_language\x18\n \x01(\x0e\x32\x1d.ray.serve.DeploymentLanguageR\x12\x64\x65ploymentLanguage\x12K\n\x12\x61utoscaling_config\x18\x0b \x01(\x0b\x32\x1c.ray.serve.AutoscalingConfigR\x11\x61utoscalingConfig\x12\x18\n\x07version\x18\x0c \x01(\tR\x07version\x12?\n\x1cuser_configured_option_names\x18\r \x03(\tR\x19userConfiguredOptionNames\x12?\n\x0elogging_config\x18\x0e \x01(\x0b\x32\x18.ray.serve.LoggingConfigR\rloggingConfig\x12R\n\x15request_router_config\x18\x13 \x01(\x0b\x32\x1e.ray.serve.RequestRouterConfigR\x13requestRouterConfig\x12=\n\x1bmax_constructor_retry_count\x18\x14 \x01(\x05R\x18maxConstructorRetryCount\"\xc8\x02\n\x0fRequestMetadata\x12\x1d\n\nrequest_id\x18\x01 \x01(\tR\trequestId\x12.\n\x13internal_request_id\x18\x02 \x01(\tR\x11internalRequestId\x12\x1f\n\x0b\x63\x61ll_method\x18\x03 \x01(\tR\ncallMethod\x12\x41\n\x07\x63ontext\x18\x04 \x03(\x0b\x32\'.ray.serve.RequestMetadata.ContextEntryR\x07\x63ontext\x12\x30\n\x14multiplexed_model_id\x18\x05 \x01(\tR\x12multiplexedModelId\x12\x14\n\x05route\x18\x06 \x01(\tR\x05route\x1a:\n\x0c\x43ontextEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\"$\n\x0eRequestWrapper\x12\x12\n\x04\x62ody\x18\x01 \x01(\x0cR\x04\x62ody\"Y\n\rUpdatedObject\x12\'\n\x0fobject_snapshot\x18\x01 \x01(\x0cR\x0eobjectSnapshot\x12\x1f\n\x0bsnapshot_id\x18\x02 \x01(\x05R\nsnapshotId\"\xbb\x01\n\x0fLongPollRequest\x12\x62\n\x14keys_to_snapshot_ids\x18\x01 \x03(\x0b\x32\x31.ray.serve.LongPollRequest.KeysToSnapshotIdsEntryR\x11keysToSnapshotIds\x1a\x44\n\x16KeysToSnapshotIdsEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\x05R\x05value:\x02\x38\x01\"\xc5\x01\n\x0eLongPollResult\x12V\n\x0fupdated_objects\x18\x01 \x03(\x0b\x32-.ray.serve.LongPollResult.UpdatedObjectsEntryR\x0eupdatedObjects\x1a[\n\x13UpdatedObjectsEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12.\n\x05value\x18\x02 \x01(\x0b\x32\x18.ray.serve.UpdatedObjectR\x05value:\x02\x38\x01\"\xc1\x01\n\x0c\x45ndpointInfo\x12#\n\rendpoint_name\x18\x01 \x01(\tR\x0c\x65ndpointName\x12\x14\n\x05route\x18\x02 \x01(\tR\x05route\x12;\n\x06\x63onfig\x18\x03 \x03(\x0b\x32#.ray.serve.EndpointInfo.ConfigEntryR\x06\x63onfig\x1a\x39\n\x0b\x43onfigEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\"\xa9\x01\n\x0b\x45ndpointSet\x12\x43\n\tendpoints\x18\x01 \x03(\x0b\x32%.ray.serve.EndpointSet.EndpointsEntryR\tendpoints\x1aU\n\x0e\x45ndpointsEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12-\n\x05value\x18\x02 \x01(\x0b\x32\x17.ray.serve.EndpointInfoR\x05value:\x02\x38\x01\"%\n\rActorNameList\x12\x14\n\x05names\x18\x01 \x03(\tR\x05names\"^\n\x14\x44\x65ploymentTargetInfo\x12#\n\rreplica_names\x18\x01 \x03(\tR\x0creplicaNames\x12!\n\x0cis_available\x18\x02 \x01(\x08R\x0bisAvailable\"\xd1\x02\n\x11\x44\x65ploymentVersion\x12!\n\x0c\x63ode_version\x18\x01 \x01(\tR\x0b\x63odeVersion\x12H\n\x11\x64\x65ployment_config\x18\x02 \x01(\x0b\x32\x1b.ray.serve.DeploymentConfigR\x10\x64\x65ploymentConfig\x12*\n\x11ray_actor_options\x18\x03 \x01(\tR\x0frayActorOptions\x12\x36\n\x17placement_group_bundles\x18\x04 \x01(\tR\x15placementGroupBundles\x12\x38\n\x18placement_group_strategy\x18\x05 \x01(\tR\x16placementGroupStrategy\x12\x31\n\x15max_replicas_per_node\x18\x06 \x01(\x05R\x12maxReplicasPerNode\"\xf5\x02\n\rReplicaConfig\x12.\n\x13\x64\x65ployment_def_name\x18\x01 \x01(\tR\x11\x64\x65ploymentDefName\x12%\n\x0e\x64\x65ployment_def\x18\x02 \x01(\x0cR\rdeploymentDef\x12\x1b\n\tinit_args\x18\x03 \x01(\x0cR\x08initArgs\x12\x1f\n\x0binit_kwargs\x18\x04 \x01(\x0cR\ninitKwargs\x12*\n\x11ray_actor_options\x18\x05 \x01(\tR\x0frayActorOptions\x12\x36\n\x17placement_group_bundles\x18\x06 \x01(\tR\x15placementGroupBundles\x12\x38\n\x18placement_group_strategy\x18\x07 \x01(\tR\x16placementGroupStrategy\x12\x31\n\x15max_replicas_per_node\x18\x08 \x01(\x05R\x12maxReplicasPerNode\"\xb5\x03\n\x0e\x44\x65ploymentInfo\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\x12H\n\x11\x64\x65ployment_config\x18\x02 \x01(\x0b\x32\x1b.ray.serve.DeploymentConfigR\x10\x64\x65ploymentConfig\x12?\n\x0ereplica_config\x18\x03 \x01(\x0b\x32\x18.ray.serve.ReplicaConfigR\rreplicaConfig\x12\"\n\rstart_time_ms\x18\x04 \x01(\x03R\x0bstartTimeMs\x12\x1d\n\nactor_name\x18\x05 \x01(\tR\tactorName\x12\x18\n\x07version\x18\x06 \x01(\tR\x07version\x12\x1e\n\x0b\x65nd_time_ms\x18\x07 \x01(\x03R\tendTimeMs\x12\'\n\x0ftarget_capacity\x18\x08 \x01(\x01R\x0etargetCapacity\x12^\n\x19target_capacity_direction\x18\t \x01(\x0e\x32\".ray.serve.TargetCapacityDirectionR\x17targetCapacityDirection\"k\n\x0f\x44\x65ploymentRoute\x12\x42\n\x0f\x64\x65ployment_info\x18\x01 \x01(\x0b\x32\x19.ray.serve.DeploymentInfoR\x0e\x64\x65ploymentInfo\x12\x14\n\x05route\x18\x02 \x01(\tR\x05route\"^\n\x13\x44\x65ploymentRouteList\x12G\n\x11\x64\x65ployment_routes\x18\x01 \x03(\x0b\x32\x1a.ray.serve.DeploymentRouteR\x10\x64\x65ploymentRoutes\"\xc4\x01\n\x14\x44\x65ploymentStatusInfo\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\x12\x33\n\x06status\x18\x02 \x01(\x0e\x32\x1b.ray.serve.DeploymentStatusR\x06status\x12\x18\n\x07message\x18\x03 \x01(\tR\x07message\x12I\n\x0estatus_trigger\x18\x04 \x01(\x0e\x32\".ray.serve.DeploymentStatusTriggerR\rstatusTrigger\"s\n\x18\x44\x65ploymentStatusInfoList\x12W\n\x17\x64\x65ployment_status_infos\x18\x01 \x03(\x0b\x32\x1f.ray.serve.DeploymentStatusInfoR\x15\x64\x65ploymentStatusInfos\"\x9a\x01\n\x15\x41pplicationStatusInfo\x12\x34\n\x06status\x18\x01 \x01(\x0e\x32\x1c.ray.serve.ApplicationStatusR\x06status\x12\x18\n\x07message\x18\x02 \x01(\tR\x07message\x12\x31\n\x14\x64\x65ployment_timestamp\x18\x03 \x01(\x01R\x13\x64\x65ploymentTimestamp\"\xbb\x01\n\x0eStatusOverview\x12?\n\napp_status\x18\x01 \x01(\x0b\x32 .ray.serve.ApplicationStatusInfoR\tappStatus\x12T\n\x13\x64\x65ployment_statuses\x18\x02 \x01(\x0b\x32#.ray.serve.DeploymentStatusInfoListR\x12\x64\x65ploymentStatuses\x12\x12\n\x04name\x18\x03 \x01(\tR\x04name\"\x19\n\x17ListApplicationsRequest\"G\n\x18ListApplicationsResponse\x12+\n\x11\x61pplication_names\x18\x01 \x03(\tR\x10\x61pplicationNames\"\x10\n\x0eHealthzRequest\"+\n\x0fHealthzResponse\x12\x18\n\x07message\x18\x01 \x01(\tR\x07message\"L\n\x12UserDefinedMessage\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\x12\x10\n\x03\x66oo\x18\x02 \x01(\tR\x03\x66oo\x12\x10\n\x03num\x18\x03 \x01(\x03R\x03num\"H\n\x13UserDefinedResponse\x12\x1a\n\x08greeting\x18\x01 \x01(\tR\x08greeting\x12\x15\n\x06num_x2\x18\x02 \x01(\x03R\x05numX2\"\x15\n\x13UserDefinedMessage2\"2\n\x14UserDefinedResponse2\x12\x1a\n\x08greeting\x18\x01 \x01(\tR\x08greeting\"T\n\x0c\x46ruitAmounts\x12\x16\n\x06orange\x18\x01 \x01(\x03R\x06orange\x12\x14\n\x05\x61pple\x18\x02 \x01(\x03R\x05\x61pple\x12\x16\n\x06\x62\x61nana\x18\x03 \x01(\x03R\x06\x62\x61nana\"\"\n\nFruitCosts\x12\x14\n\x05\x63osts\x18\x01 \x01(\x02R\x05\x63osts\"\x1f\n\tArrayData\x12\x12\n\x04nums\x18\x01 \x03(\x02R\x04nums\" \n\nStringData\x12\x12\n\x04\x64\x61ta\x18\x01 \x01(\tR\x04\x64\x61ta\"%\n\x0bModelOutput\x12\x16\n\x06output\x18\x01 \x01(\x02R\x06output\"\xb8\x02\n\x0e\x44\x65ploymentArgs\x12\'\n\x0f\x64\x65ployment_name\x18\x01 \x01(\tR\x0e\x64\x65ploymentName\x12+\n\x11\x64\x65ployment_config\x18\x02 \x01(\x0cR\x10\x64\x65ploymentConfig\x12%\n\x0ereplica_config\x18\x03 \x01(\x0cR\rreplicaConfig\x12&\n\x0f\x64\x65ployer_job_id\x18\x04 \x01(\tR\rdeployerJobId\x12&\n\x0croute_prefix\x18\x05 \x01(\tH\x00R\x0broutePrefix\x88\x01\x01\x12\x18\n\x07ingress\x18\x06 \x01(\x08R\x07ingress\x12 \n\tdocs_path\x18\x07 \x01(\tH\x01R\x08\x64ocsPath\x88\x01\x01\x42\x0f\n\r_route_prefixB\x0c\n\n_docs_path*\"\n\x0c\x45ncodingType\x12\x08\n\x04TEXT\x10\x00\x12\x08\n\x04JSON\x10\x01**\n\x12\x44\x65ploymentLanguage\x12\n\n\x06PYTHON\x10\x00\x12\x08\n\x04JAVA\x10\x01*6\n\x17TargetCapacityDirection\x12\t\n\x05UNSET\x10\x00\x12\x06\n\x02UP\x10\x01\x12\x08\n\x04\x44OWN\x10\x02*\xdb\x01\n\x10\x44\x65ploymentStatus\x12\x1e\n\x1a\x44\x45PLOYMENT_STATUS_UPDATING\x10\x00\x12\x1d\n\x19\x44\x45PLOYMENT_STATUS_HEALTHY\x10\x01\x12\x1f\n\x1b\x44\x45PLOYMENT_STATUS_UNHEALTHY\x10\x02\x12#\n\x1f\x44\x45PLOYMENT_STATUS_DEPLOY_FAILED\x10\x03\x12\x1f\n\x1b\x44\x45PLOYMENT_STATUS_UPSCALING\x10\x04\x12!\n\x1d\x44\x45PLOYMENT_STATUS_DOWNSCALING\x10\x05*\xfe\x03\n\x17\x44\x65ploymentStatusTrigger\x12)\n%DEPLOYMENT_STATUS_TRIGGER_UNSPECIFIED\x10\x00\x12\x33\n/DEPLOYMENT_STATUS_TRIGGER_CONFIG_UPDATE_STARTED\x10\x01\x12\x35\n1DEPLOYMENT_STATUS_TRIGGER_CONFIG_UPDATE_COMPLETED\x10\x02\x12/\n+DEPLOYMENT_STATUS_TRIGGER_UPSCALE_COMPLETED\x10\x03\x12\x31\n-DEPLOYMENT_STATUS_TRIGGER_DOWNSCALE_COMPLETED\x10\x04\x12)\n%DEPLOYMENT_STATUS_TRIGGER_AUTOSCALING\x10\x05\x12\x34\n0DEPLOYMENT_STATUS_TRIGGER_REPLICA_STARTUP_FAILED\x10\x06\x12\x31\n-DEPLOYMENT_STATUS_TRIGGER_HEALTH_CHECK_FAILED\x10\x07\x12,\n(DEPLOYMENT_STATUS_TRIGGER_INTERNAL_ERROR\x10\x08\x12&\n\"DEPLOYMENT_STATUS_TRIGGER_DELETING\x10\t*\xe2\x01\n\x11\x41pplicationStatus\x12 \n\x1c\x41PPLICATION_STATUS_DEPLOYING\x10\x00\x12\x1e\n\x1a\x41PPLICATION_STATUS_RUNNING\x10\x01\x12$\n APPLICATION_STATUS_DEPLOY_FAILED\x10\x02\x12\x1f\n\x1b\x41PPLICATION_STATUS_DELETING\x10\x03\x12\"\n\x1e\x41PPLICATION_STATUS_NOT_STARTED\x10\x05\x12 \n\x1c\x41PPLICATION_STATUS_UNHEALTHY\x10\x06\x32\xb3\x01\n\x12RayServeAPIService\x12[\n\x10ListApplications\x12\".ray.serve.ListApplicationsRequest\x1a#.ray.serve.ListApplicationsResponse\x12@\n\x07Healthz\x12\x19.ray.serve.HealthzRequest\x1a\x1a.ray.serve.HealthzResponse2\xc3\x02\n\x12UserDefinedService\x12I\n\x08__call__\x12\x1d.ray.serve.UserDefinedMessage\x1a\x1e.ray.serve.UserDefinedResponse\x12H\n\x07Method1\x12\x1d.ray.serve.UserDefinedMessage\x1a\x1e.ray.serve.UserDefinedResponse\x12J\n\x07Method2\x12\x1e.ray.serve.UserDefinedMessage2\x1a\x1f.ray.serve.UserDefinedResponse2\x12L\n\tStreaming\x12\x1d.ray.serve.UserDefinedMessage\x1a\x1e.ray.serve.UserDefinedResponse0\x01\x32L\n\x0c\x46ruitService\x12<\n\nFruitStand\x12\x17.ray.serve.FruitAmounts\x1a\x15.ray.serve.FruitCosts2\x98\x01\n\x18RayServeBenchmarkService\x12\x39\n\tgrpc_call\x12\x14.ray.serve.ArrayData\x1a\x16.ray.serve.ModelOutput\x12\x41\n\x10\x63\x61ll_with_string\x12\x15.ray.serve.StringData\x1a\x16.ray.serve.ModelOutputB\'\n\x16io.ray.serve.generatedB\x0bServeProtosP\x01\x62\x06proto3') + +_ENCODINGTYPE = DESCRIPTOR.enum_types_by_name['EncodingType'] +EncodingType = enum_type_wrapper.EnumTypeWrapper(_ENCODINGTYPE) +_DEPLOYMENTLANGUAGE = DESCRIPTOR.enum_types_by_name['DeploymentLanguage'] +DeploymentLanguage = enum_type_wrapper.EnumTypeWrapper(_DEPLOYMENTLANGUAGE) +_TARGETCAPACITYDIRECTION = DESCRIPTOR.enum_types_by_name['TargetCapacityDirection'] +TargetCapacityDirection = enum_type_wrapper.EnumTypeWrapper(_TARGETCAPACITYDIRECTION) +_DEPLOYMENTSTATUS = DESCRIPTOR.enum_types_by_name['DeploymentStatus'] +DeploymentStatus = enum_type_wrapper.EnumTypeWrapper(_DEPLOYMENTSTATUS) +_DEPLOYMENTSTATUSTRIGGER = DESCRIPTOR.enum_types_by_name['DeploymentStatusTrigger'] +DeploymentStatusTrigger = enum_type_wrapper.EnumTypeWrapper(_DEPLOYMENTSTATUSTRIGGER) +_APPLICATIONSTATUS = DESCRIPTOR.enum_types_by_name['ApplicationStatus'] +ApplicationStatus = enum_type_wrapper.EnumTypeWrapper(_APPLICATIONSTATUS) +TEXT = 0 +JSON = 1 +PYTHON = 0 +JAVA = 1 +UNSET = 0 +UP = 1 +DOWN = 2 +DEPLOYMENT_STATUS_UPDATING = 0 +DEPLOYMENT_STATUS_HEALTHY = 1 +DEPLOYMENT_STATUS_UNHEALTHY = 2 +DEPLOYMENT_STATUS_DEPLOY_FAILED = 3 +DEPLOYMENT_STATUS_UPSCALING = 4 +DEPLOYMENT_STATUS_DOWNSCALING = 5 +DEPLOYMENT_STATUS_TRIGGER_UNSPECIFIED = 0 +DEPLOYMENT_STATUS_TRIGGER_CONFIG_UPDATE_STARTED = 1 +DEPLOYMENT_STATUS_TRIGGER_CONFIG_UPDATE_COMPLETED = 2 +DEPLOYMENT_STATUS_TRIGGER_UPSCALE_COMPLETED = 3 +DEPLOYMENT_STATUS_TRIGGER_DOWNSCALE_COMPLETED = 4 +DEPLOYMENT_STATUS_TRIGGER_AUTOSCALING = 5 +DEPLOYMENT_STATUS_TRIGGER_REPLICA_STARTUP_FAILED = 6 +DEPLOYMENT_STATUS_TRIGGER_HEALTH_CHECK_FAILED = 7 +DEPLOYMENT_STATUS_TRIGGER_INTERNAL_ERROR = 8 +DEPLOYMENT_STATUS_TRIGGER_DELETING = 9 +APPLICATION_STATUS_DEPLOYING = 0 +APPLICATION_STATUS_RUNNING = 1 +APPLICATION_STATUS_DEPLOY_FAILED = 2 +APPLICATION_STATUS_DELETING = 3 +APPLICATION_STATUS_NOT_STARTED = 5 +APPLICATION_STATUS_UNHEALTHY = 6 + + +_AUTOSCALINGPOLICY = DESCRIPTOR.message_types_by_name['AutoscalingPolicy'] +_AUTOSCALINGCONFIG = DESCRIPTOR.message_types_by_name['AutoscalingConfig'] +_LOGGINGCONFIG = DESCRIPTOR.message_types_by_name['LoggingConfig'] +_REQUESTROUTERCONFIG = DESCRIPTOR.message_types_by_name['RequestRouterConfig'] +_DEPLOYMENTCONFIG = DESCRIPTOR.message_types_by_name['DeploymentConfig'] +_REQUESTMETADATA = DESCRIPTOR.message_types_by_name['RequestMetadata'] +_REQUESTMETADATA_CONTEXTENTRY = _REQUESTMETADATA.nested_types_by_name['ContextEntry'] +_REQUESTWRAPPER = DESCRIPTOR.message_types_by_name['RequestWrapper'] +_UPDATEDOBJECT = DESCRIPTOR.message_types_by_name['UpdatedObject'] +_LONGPOLLREQUEST = DESCRIPTOR.message_types_by_name['LongPollRequest'] +_LONGPOLLREQUEST_KEYSTOSNAPSHOTIDSENTRY = _LONGPOLLREQUEST.nested_types_by_name['KeysToSnapshotIdsEntry'] +_LONGPOLLRESULT = DESCRIPTOR.message_types_by_name['LongPollResult'] +_LONGPOLLRESULT_UPDATEDOBJECTSENTRY = _LONGPOLLRESULT.nested_types_by_name['UpdatedObjectsEntry'] +_ENDPOINTINFO = DESCRIPTOR.message_types_by_name['EndpointInfo'] +_ENDPOINTINFO_CONFIGENTRY = _ENDPOINTINFO.nested_types_by_name['ConfigEntry'] +_ENDPOINTSET = DESCRIPTOR.message_types_by_name['EndpointSet'] +_ENDPOINTSET_ENDPOINTSENTRY = _ENDPOINTSET.nested_types_by_name['EndpointsEntry'] +_ACTORNAMELIST = DESCRIPTOR.message_types_by_name['ActorNameList'] +_DEPLOYMENTTARGETINFO = DESCRIPTOR.message_types_by_name['DeploymentTargetInfo'] +_DEPLOYMENTVERSION = DESCRIPTOR.message_types_by_name['DeploymentVersion'] +_REPLICACONFIG = DESCRIPTOR.message_types_by_name['ReplicaConfig'] +_DEPLOYMENTINFO = DESCRIPTOR.message_types_by_name['DeploymentInfo'] +_DEPLOYMENTROUTE = DESCRIPTOR.message_types_by_name['DeploymentRoute'] +_DEPLOYMENTROUTELIST = DESCRIPTOR.message_types_by_name['DeploymentRouteList'] +_DEPLOYMENTSTATUSINFO = DESCRIPTOR.message_types_by_name['DeploymentStatusInfo'] +_DEPLOYMENTSTATUSINFOLIST = DESCRIPTOR.message_types_by_name['DeploymentStatusInfoList'] +_APPLICATIONSTATUSINFO = DESCRIPTOR.message_types_by_name['ApplicationStatusInfo'] +_STATUSOVERVIEW = DESCRIPTOR.message_types_by_name['StatusOverview'] +_LISTAPPLICATIONSREQUEST = DESCRIPTOR.message_types_by_name['ListApplicationsRequest'] +_LISTAPPLICATIONSRESPONSE = DESCRIPTOR.message_types_by_name['ListApplicationsResponse'] +_HEALTHZREQUEST = DESCRIPTOR.message_types_by_name['HealthzRequest'] +_HEALTHZRESPONSE = DESCRIPTOR.message_types_by_name['HealthzResponse'] +_USERDEFINEDMESSAGE = DESCRIPTOR.message_types_by_name['UserDefinedMessage'] +_USERDEFINEDRESPONSE = DESCRIPTOR.message_types_by_name['UserDefinedResponse'] +_USERDEFINEDMESSAGE2 = DESCRIPTOR.message_types_by_name['UserDefinedMessage2'] +_USERDEFINEDRESPONSE2 = DESCRIPTOR.message_types_by_name['UserDefinedResponse2'] +_FRUITAMOUNTS = DESCRIPTOR.message_types_by_name['FruitAmounts'] +_FRUITCOSTS = DESCRIPTOR.message_types_by_name['FruitCosts'] +_ARRAYDATA = DESCRIPTOR.message_types_by_name['ArrayData'] +_STRINGDATA = DESCRIPTOR.message_types_by_name['StringData'] +_MODELOUTPUT = DESCRIPTOR.message_types_by_name['ModelOutput'] +_DEPLOYMENTARGS = DESCRIPTOR.message_types_by_name['DeploymentArgs'] +AutoscalingPolicy = _reflection.GeneratedProtocolMessageType('AutoscalingPolicy', (_message.Message,), { + 'DESCRIPTOR' : _AUTOSCALINGPOLICY, + '__module__' : 'ray.serve.generated.serve_pb2' + # @@protoc_insertion_point(class_scope:ray.serve.AutoscalingPolicy) + }) +_sym_db.RegisterMessage(AutoscalingPolicy) + +AutoscalingConfig = _reflection.GeneratedProtocolMessageType('AutoscalingConfig', (_message.Message,), { + 'DESCRIPTOR' : _AUTOSCALINGCONFIG, + '__module__' : 'ray.serve.generated.serve_pb2' + # @@protoc_insertion_point(class_scope:ray.serve.AutoscalingConfig) + }) +_sym_db.RegisterMessage(AutoscalingConfig) + +LoggingConfig = _reflection.GeneratedProtocolMessageType('LoggingConfig', (_message.Message,), { + 'DESCRIPTOR' : _LOGGINGCONFIG, + '__module__' : 'ray.serve.generated.serve_pb2' + # @@protoc_insertion_point(class_scope:ray.serve.LoggingConfig) + }) +_sym_db.RegisterMessage(LoggingConfig) + +RequestRouterConfig = _reflection.GeneratedProtocolMessageType('RequestRouterConfig', (_message.Message,), { + 'DESCRIPTOR' : _REQUESTROUTERCONFIG, + '__module__' : 'ray.serve.generated.serve_pb2' + # @@protoc_insertion_point(class_scope:ray.serve.RequestRouterConfig) + }) +_sym_db.RegisterMessage(RequestRouterConfig) + +DeploymentConfig = _reflection.GeneratedProtocolMessageType('DeploymentConfig', (_message.Message,), { + 'DESCRIPTOR' : _DEPLOYMENTCONFIG, + '__module__' : 'ray.serve.generated.serve_pb2' + # @@protoc_insertion_point(class_scope:ray.serve.DeploymentConfig) + }) +_sym_db.RegisterMessage(DeploymentConfig) + +RequestMetadata = _reflection.GeneratedProtocolMessageType('RequestMetadata', (_message.Message,), { + + 'ContextEntry' : _reflection.GeneratedProtocolMessageType('ContextEntry', (_message.Message,), { + 'DESCRIPTOR' : _REQUESTMETADATA_CONTEXTENTRY, + '__module__' : 'ray.serve.generated.serve_pb2' + # @@protoc_insertion_point(class_scope:ray.serve.RequestMetadata.ContextEntry) + }) + , + 'DESCRIPTOR' : _REQUESTMETADATA, + '__module__' : 'ray.serve.generated.serve_pb2' + # @@protoc_insertion_point(class_scope:ray.serve.RequestMetadata) + }) +_sym_db.RegisterMessage(RequestMetadata) +_sym_db.RegisterMessage(RequestMetadata.ContextEntry) + +RequestWrapper = _reflection.GeneratedProtocolMessageType('RequestWrapper', (_message.Message,), { + 'DESCRIPTOR' : _REQUESTWRAPPER, + '__module__' : 'ray.serve.generated.serve_pb2' + # @@protoc_insertion_point(class_scope:ray.serve.RequestWrapper) + }) +_sym_db.RegisterMessage(RequestWrapper) + +UpdatedObject = _reflection.GeneratedProtocolMessageType('UpdatedObject', (_message.Message,), { + 'DESCRIPTOR' : _UPDATEDOBJECT, + '__module__' : 'ray.serve.generated.serve_pb2' + # @@protoc_insertion_point(class_scope:ray.serve.UpdatedObject) + }) +_sym_db.RegisterMessage(UpdatedObject) + +LongPollRequest = _reflection.GeneratedProtocolMessageType('LongPollRequest', (_message.Message,), { + + 'KeysToSnapshotIdsEntry' : _reflection.GeneratedProtocolMessageType('KeysToSnapshotIdsEntry', (_message.Message,), { + 'DESCRIPTOR' : _LONGPOLLREQUEST_KEYSTOSNAPSHOTIDSENTRY, + '__module__' : 'ray.serve.generated.serve_pb2' + # @@protoc_insertion_point(class_scope:ray.serve.LongPollRequest.KeysToSnapshotIdsEntry) + }) + , + 'DESCRIPTOR' : _LONGPOLLREQUEST, + '__module__' : 'ray.serve.generated.serve_pb2' + # @@protoc_insertion_point(class_scope:ray.serve.LongPollRequest) + }) +_sym_db.RegisterMessage(LongPollRequest) +_sym_db.RegisterMessage(LongPollRequest.KeysToSnapshotIdsEntry) + +LongPollResult = _reflection.GeneratedProtocolMessageType('LongPollResult', (_message.Message,), { + + 'UpdatedObjectsEntry' : _reflection.GeneratedProtocolMessageType('UpdatedObjectsEntry', (_message.Message,), { + 'DESCRIPTOR' : _LONGPOLLRESULT_UPDATEDOBJECTSENTRY, + '__module__' : 'ray.serve.generated.serve_pb2' + # @@protoc_insertion_point(class_scope:ray.serve.LongPollResult.UpdatedObjectsEntry) + }) + , + 'DESCRIPTOR' : _LONGPOLLRESULT, + '__module__' : 'ray.serve.generated.serve_pb2' + # @@protoc_insertion_point(class_scope:ray.serve.LongPollResult) + }) +_sym_db.RegisterMessage(LongPollResult) +_sym_db.RegisterMessage(LongPollResult.UpdatedObjectsEntry) + +EndpointInfo = _reflection.GeneratedProtocolMessageType('EndpointInfo', (_message.Message,), { + + 'ConfigEntry' : _reflection.GeneratedProtocolMessageType('ConfigEntry', (_message.Message,), { + 'DESCRIPTOR' : _ENDPOINTINFO_CONFIGENTRY, + '__module__' : 'ray.serve.generated.serve_pb2' + # @@protoc_insertion_point(class_scope:ray.serve.EndpointInfo.ConfigEntry) + }) + , + 'DESCRIPTOR' : _ENDPOINTINFO, + '__module__' : 'ray.serve.generated.serve_pb2' + # @@protoc_insertion_point(class_scope:ray.serve.EndpointInfo) + }) +_sym_db.RegisterMessage(EndpointInfo) +_sym_db.RegisterMessage(EndpointInfo.ConfigEntry) + +EndpointSet = _reflection.GeneratedProtocolMessageType('EndpointSet', (_message.Message,), { + + 'EndpointsEntry' : _reflection.GeneratedProtocolMessageType('EndpointsEntry', (_message.Message,), { + 'DESCRIPTOR' : _ENDPOINTSET_ENDPOINTSENTRY, + '__module__' : 'ray.serve.generated.serve_pb2' + # @@protoc_insertion_point(class_scope:ray.serve.EndpointSet.EndpointsEntry) + }) + , + 'DESCRIPTOR' : _ENDPOINTSET, + '__module__' : 'ray.serve.generated.serve_pb2' + # @@protoc_insertion_point(class_scope:ray.serve.EndpointSet) + }) +_sym_db.RegisterMessage(EndpointSet) +_sym_db.RegisterMessage(EndpointSet.EndpointsEntry) + +ActorNameList = _reflection.GeneratedProtocolMessageType('ActorNameList', (_message.Message,), { + 'DESCRIPTOR' : _ACTORNAMELIST, + '__module__' : 'ray.serve.generated.serve_pb2' + # @@protoc_insertion_point(class_scope:ray.serve.ActorNameList) + }) +_sym_db.RegisterMessage(ActorNameList) + +DeploymentTargetInfo = _reflection.GeneratedProtocolMessageType('DeploymentTargetInfo', (_message.Message,), { + 'DESCRIPTOR' : _DEPLOYMENTTARGETINFO, + '__module__' : 'ray.serve.generated.serve_pb2' + # @@protoc_insertion_point(class_scope:ray.serve.DeploymentTargetInfo) + }) +_sym_db.RegisterMessage(DeploymentTargetInfo) + +DeploymentVersion = _reflection.GeneratedProtocolMessageType('DeploymentVersion', (_message.Message,), { + 'DESCRIPTOR' : _DEPLOYMENTVERSION, + '__module__' : 'ray.serve.generated.serve_pb2' + # @@protoc_insertion_point(class_scope:ray.serve.DeploymentVersion) + }) +_sym_db.RegisterMessage(DeploymentVersion) + +ReplicaConfig = _reflection.GeneratedProtocolMessageType('ReplicaConfig', (_message.Message,), { + 'DESCRIPTOR' : _REPLICACONFIG, + '__module__' : 'ray.serve.generated.serve_pb2' + # @@protoc_insertion_point(class_scope:ray.serve.ReplicaConfig) + }) +_sym_db.RegisterMessage(ReplicaConfig) + +DeploymentInfo = _reflection.GeneratedProtocolMessageType('DeploymentInfo', (_message.Message,), { + 'DESCRIPTOR' : _DEPLOYMENTINFO, + '__module__' : 'ray.serve.generated.serve_pb2' + # @@protoc_insertion_point(class_scope:ray.serve.DeploymentInfo) + }) +_sym_db.RegisterMessage(DeploymentInfo) + +DeploymentRoute = _reflection.GeneratedProtocolMessageType('DeploymentRoute', (_message.Message,), { + 'DESCRIPTOR' : _DEPLOYMENTROUTE, + '__module__' : 'ray.serve.generated.serve_pb2' + # @@protoc_insertion_point(class_scope:ray.serve.DeploymentRoute) + }) +_sym_db.RegisterMessage(DeploymentRoute) + +DeploymentRouteList = _reflection.GeneratedProtocolMessageType('DeploymentRouteList', (_message.Message,), { + 'DESCRIPTOR' : _DEPLOYMENTROUTELIST, + '__module__' : 'ray.serve.generated.serve_pb2' + # @@protoc_insertion_point(class_scope:ray.serve.DeploymentRouteList) + }) +_sym_db.RegisterMessage(DeploymentRouteList) + +DeploymentStatusInfo = _reflection.GeneratedProtocolMessageType('DeploymentStatusInfo', (_message.Message,), { + 'DESCRIPTOR' : _DEPLOYMENTSTATUSINFO, + '__module__' : 'ray.serve.generated.serve_pb2' + # @@protoc_insertion_point(class_scope:ray.serve.DeploymentStatusInfo) + }) +_sym_db.RegisterMessage(DeploymentStatusInfo) + +DeploymentStatusInfoList = _reflection.GeneratedProtocolMessageType('DeploymentStatusInfoList', (_message.Message,), { + 'DESCRIPTOR' : _DEPLOYMENTSTATUSINFOLIST, + '__module__' : 'ray.serve.generated.serve_pb2' + # @@protoc_insertion_point(class_scope:ray.serve.DeploymentStatusInfoList) + }) +_sym_db.RegisterMessage(DeploymentStatusInfoList) + +ApplicationStatusInfo = _reflection.GeneratedProtocolMessageType('ApplicationStatusInfo', (_message.Message,), { + 'DESCRIPTOR' : _APPLICATIONSTATUSINFO, + '__module__' : 'ray.serve.generated.serve_pb2' + # @@protoc_insertion_point(class_scope:ray.serve.ApplicationStatusInfo) + }) +_sym_db.RegisterMessage(ApplicationStatusInfo) + +StatusOverview = _reflection.GeneratedProtocolMessageType('StatusOverview', (_message.Message,), { + 'DESCRIPTOR' : _STATUSOVERVIEW, + '__module__' : 'ray.serve.generated.serve_pb2' + # @@protoc_insertion_point(class_scope:ray.serve.StatusOverview) + }) +_sym_db.RegisterMessage(StatusOverview) + +ListApplicationsRequest = _reflection.GeneratedProtocolMessageType('ListApplicationsRequest', (_message.Message,), { + 'DESCRIPTOR' : _LISTAPPLICATIONSREQUEST, + '__module__' : 'ray.serve.generated.serve_pb2' + # @@protoc_insertion_point(class_scope:ray.serve.ListApplicationsRequest) + }) +_sym_db.RegisterMessage(ListApplicationsRequest) + +ListApplicationsResponse = _reflection.GeneratedProtocolMessageType('ListApplicationsResponse', (_message.Message,), { + 'DESCRIPTOR' : _LISTAPPLICATIONSRESPONSE, + '__module__' : 'ray.serve.generated.serve_pb2' + # @@protoc_insertion_point(class_scope:ray.serve.ListApplicationsResponse) + }) +_sym_db.RegisterMessage(ListApplicationsResponse) + +HealthzRequest = _reflection.GeneratedProtocolMessageType('HealthzRequest', (_message.Message,), { + 'DESCRIPTOR' : _HEALTHZREQUEST, + '__module__' : 'ray.serve.generated.serve_pb2' + # @@protoc_insertion_point(class_scope:ray.serve.HealthzRequest) + }) +_sym_db.RegisterMessage(HealthzRequest) + +HealthzResponse = _reflection.GeneratedProtocolMessageType('HealthzResponse', (_message.Message,), { + 'DESCRIPTOR' : _HEALTHZRESPONSE, + '__module__' : 'ray.serve.generated.serve_pb2' + # @@protoc_insertion_point(class_scope:ray.serve.HealthzResponse) + }) +_sym_db.RegisterMessage(HealthzResponse) + +UserDefinedMessage = _reflection.GeneratedProtocolMessageType('UserDefinedMessage', (_message.Message,), { + 'DESCRIPTOR' : _USERDEFINEDMESSAGE, + '__module__' : 'ray.serve.generated.serve_pb2' + # @@protoc_insertion_point(class_scope:ray.serve.UserDefinedMessage) + }) +_sym_db.RegisterMessage(UserDefinedMessage) + +UserDefinedResponse = _reflection.GeneratedProtocolMessageType('UserDefinedResponse', (_message.Message,), { + 'DESCRIPTOR' : _USERDEFINEDRESPONSE, + '__module__' : 'ray.serve.generated.serve_pb2' + # @@protoc_insertion_point(class_scope:ray.serve.UserDefinedResponse) + }) +_sym_db.RegisterMessage(UserDefinedResponse) + +UserDefinedMessage2 = _reflection.GeneratedProtocolMessageType('UserDefinedMessage2', (_message.Message,), { + 'DESCRIPTOR' : _USERDEFINEDMESSAGE2, + '__module__' : 'ray.serve.generated.serve_pb2' + # @@protoc_insertion_point(class_scope:ray.serve.UserDefinedMessage2) + }) +_sym_db.RegisterMessage(UserDefinedMessage2) + +UserDefinedResponse2 = _reflection.GeneratedProtocolMessageType('UserDefinedResponse2', (_message.Message,), { + 'DESCRIPTOR' : _USERDEFINEDRESPONSE2, + '__module__' : 'ray.serve.generated.serve_pb2' + # @@protoc_insertion_point(class_scope:ray.serve.UserDefinedResponse2) + }) +_sym_db.RegisterMessage(UserDefinedResponse2) + +FruitAmounts = _reflection.GeneratedProtocolMessageType('FruitAmounts', (_message.Message,), { + 'DESCRIPTOR' : _FRUITAMOUNTS, + '__module__' : 'ray.serve.generated.serve_pb2' + # @@protoc_insertion_point(class_scope:ray.serve.FruitAmounts) + }) +_sym_db.RegisterMessage(FruitAmounts) + +FruitCosts = _reflection.GeneratedProtocolMessageType('FruitCosts', (_message.Message,), { + 'DESCRIPTOR' : _FRUITCOSTS, + '__module__' : 'ray.serve.generated.serve_pb2' + # @@protoc_insertion_point(class_scope:ray.serve.FruitCosts) + }) +_sym_db.RegisterMessage(FruitCosts) + +ArrayData = _reflection.GeneratedProtocolMessageType('ArrayData', (_message.Message,), { + 'DESCRIPTOR' : _ARRAYDATA, + '__module__' : 'ray.serve.generated.serve_pb2' + # @@protoc_insertion_point(class_scope:ray.serve.ArrayData) + }) +_sym_db.RegisterMessage(ArrayData) + +StringData = _reflection.GeneratedProtocolMessageType('StringData', (_message.Message,), { + 'DESCRIPTOR' : _STRINGDATA, + '__module__' : 'ray.serve.generated.serve_pb2' + # @@protoc_insertion_point(class_scope:ray.serve.StringData) + }) +_sym_db.RegisterMessage(StringData) + +ModelOutput = _reflection.GeneratedProtocolMessageType('ModelOutput', (_message.Message,), { + 'DESCRIPTOR' : _MODELOUTPUT, + '__module__' : 'ray.serve.generated.serve_pb2' + # @@protoc_insertion_point(class_scope:ray.serve.ModelOutput) + }) +_sym_db.RegisterMessage(ModelOutput) + +DeploymentArgs = _reflection.GeneratedProtocolMessageType('DeploymentArgs', (_message.Message,), { + 'DESCRIPTOR' : _DEPLOYMENTARGS, + '__module__' : 'ray.serve.generated.serve_pb2' + # @@protoc_insertion_point(class_scope:ray.serve.DeploymentArgs) + }) +_sym_db.RegisterMessage(DeploymentArgs) + +_RAYSERVEAPISERVICE = DESCRIPTOR.services_by_name['RayServeAPIService'] +_USERDEFINEDSERVICE = DESCRIPTOR.services_by_name['UserDefinedService'] +_FRUITSERVICE = DESCRIPTOR.services_by_name['FruitService'] +_RAYSERVEBENCHMARKSERVICE = DESCRIPTOR.services_by_name['RayServeBenchmarkService'] +if _descriptor._USE_C_DESCRIPTORS == False: + + DESCRIPTOR._options = None + DESCRIPTOR._serialized_options = b'\n\026io.ray.serve.generatedB\013ServeProtosP\001' + _REQUESTMETADATA_CONTEXTENTRY._options = None + _REQUESTMETADATA_CONTEXTENTRY._serialized_options = b'8\001' + _LONGPOLLREQUEST_KEYSTOSNAPSHOTIDSENTRY._options = None + _LONGPOLLREQUEST_KEYSTOSNAPSHOTIDSENTRY._serialized_options = b'8\001' + _LONGPOLLRESULT_UPDATEDOBJECTSENTRY._options = None + _LONGPOLLRESULT_UPDATEDOBJECTSENTRY._serialized_options = b'8\001' + _ENDPOINTINFO_CONFIGENTRY._options = None + _ENDPOINTINFO_CONFIGENTRY._serialized_options = b'8\001' + _ENDPOINTSET_ENDPOINTSENTRY._options = None + _ENDPOINTSET_ENDPOINTSENTRY._serialized_options = b'8\001' + _ENCODINGTYPE._serialized_start=6929 + _ENCODINGTYPE._serialized_end=6963 + _DEPLOYMENTLANGUAGE._serialized_start=6965 + _DEPLOYMENTLANGUAGE._serialized_end=7007 + _TARGETCAPACITYDIRECTION._serialized_start=7009 + _TARGETCAPACITYDIRECTION._serialized_end=7063 + _DEPLOYMENTSTATUS._serialized_start=7066 + _DEPLOYMENTSTATUS._serialized_end=7285 + _DEPLOYMENTSTATUSTRIGGER._serialized_start=7288 + _DEPLOYMENTSTATUSTRIGGER._serialized_end=7798 + _APPLICATIONSTATUS._serialized_start=7801 + _APPLICATIONSTATUS._serialized_end=8027 + _AUTOSCALINGPOLICY._serialized_start=43 + _AUTOSCALINGPOLICY._serialized_end=156 + _AUTOSCALINGCONFIG._serialized_start=159 + _AUTOSCALINGCONFIG._serialized_end=1123 + _LOGGINGCONFIG._serialized_start=1126 + _LOGGINGCONFIG._serialized_end=1361 + _REQUESTROUTERCONFIG._serialized_start=1364 + _REQUESTROUTERCONFIG._serialized_end=1693 + _DEPLOYMENTCONFIG._serialized_start=1696 + _DEPLOYMENTCONFIG._serialized_end=2617 + _REQUESTMETADATA._serialized_start=2620 + _REQUESTMETADATA._serialized_end=2948 + _REQUESTMETADATA_CONTEXTENTRY._serialized_start=2890 + _REQUESTMETADATA_CONTEXTENTRY._serialized_end=2948 + _REQUESTWRAPPER._serialized_start=2950 + _REQUESTWRAPPER._serialized_end=2986 + _UPDATEDOBJECT._serialized_start=2988 + _UPDATEDOBJECT._serialized_end=3077 + _LONGPOLLREQUEST._serialized_start=3080 + _LONGPOLLREQUEST._serialized_end=3267 + _LONGPOLLREQUEST_KEYSTOSNAPSHOTIDSENTRY._serialized_start=3199 + _LONGPOLLREQUEST_KEYSTOSNAPSHOTIDSENTRY._serialized_end=3267 + _LONGPOLLRESULT._serialized_start=3270 + _LONGPOLLRESULT._serialized_end=3467 + _LONGPOLLRESULT_UPDATEDOBJECTSENTRY._serialized_start=3376 + _LONGPOLLRESULT_UPDATEDOBJECTSENTRY._serialized_end=3467 + _ENDPOINTINFO._serialized_start=3470 + _ENDPOINTINFO._serialized_end=3663 + _ENDPOINTINFO_CONFIGENTRY._serialized_start=3606 + _ENDPOINTINFO_CONFIGENTRY._serialized_end=3663 + _ENDPOINTSET._serialized_start=3666 + _ENDPOINTSET._serialized_end=3835 + _ENDPOINTSET_ENDPOINTSENTRY._serialized_start=3750 + _ENDPOINTSET_ENDPOINTSENTRY._serialized_end=3835 + _ACTORNAMELIST._serialized_start=3837 + _ACTORNAMELIST._serialized_end=3874 + _DEPLOYMENTTARGETINFO._serialized_start=3876 + _DEPLOYMENTTARGETINFO._serialized_end=3970 + _DEPLOYMENTVERSION._serialized_start=3973 + _DEPLOYMENTVERSION._serialized_end=4310 + _REPLICACONFIG._serialized_start=4313 + _REPLICACONFIG._serialized_end=4686 + _DEPLOYMENTINFO._serialized_start=4689 + _DEPLOYMENTINFO._serialized_end=5126 + _DEPLOYMENTROUTE._serialized_start=5128 + _DEPLOYMENTROUTE._serialized_end=5235 + _DEPLOYMENTROUTELIST._serialized_start=5237 + _DEPLOYMENTROUTELIST._serialized_end=5331 + _DEPLOYMENTSTATUSINFO._serialized_start=5334 + _DEPLOYMENTSTATUSINFO._serialized_end=5530 + _DEPLOYMENTSTATUSINFOLIST._serialized_start=5532 + _DEPLOYMENTSTATUSINFOLIST._serialized_end=5647 + _APPLICATIONSTATUSINFO._serialized_start=5650 + _APPLICATIONSTATUSINFO._serialized_end=5804 + _STATUSOVERVIEW._serialized_start=5807 + _STATUSOVERVIEW._serialized_end=5994 + _LISTAPPLICATIONSREQUEST._serialized_start=5996 + _LISTAPPLICATIONSREQUEST._serialized_end=6021 + _LISTAPPLICATIONSRESPONSE._serialized_start=6023 + _LISTAPPLICATIONSRESPONSE._serialized_end=6094 + _HEALTHZREQUEST._serialized_start=6096 + _HEALTHZREQUEST._serialized_end=6112 + _HEALTHZRESPONSE._serialized_start=6114 + _HEALTHZRESPONSE._serialized_end=6157 + _USERDEFINEDMESSAGE._serialized_start=6159 + _USERDEFINEDMESSAGE._serialized_end=6235 + _USERDEFINEDRESPONSE._serialized_start=6237 + _USERDEFINEDRESPONSE._serialized_end=6309 + _USERDEFINEDMESSAGE2._serialized_start=6311 + _USERDEFINEDMESSAGE2._serialized_end=6332 + _USERDEFINEDRESPONSE2._serialized_start=6334 + _USERDEFINEDRESPONSE2._serialized_end=6384 + _FRUITAMOUNTS._serialized_start=6386 + _FRUITAMOUNTS._serialized_end=6470 + _FRUITCOSTS._serialized_start=6472 + _FRUITCOSTS._serialized_end=6506 + _ARRAYDATA._serialized_start=6508 + _ARRAYDATA._serialized_end=6539 + _STRINGDATA._serialized_start=6541 + _STRINGDATA._serialized_end=6573 + _MODELOUTPUT._serialized_start=6575 + _MODELOUTPUT._serialized_end=6612 + _DEPLOYMENTARGS._serialized_start=6615 + _DEPLOYMENTARGS._serialized_end=6927 + _RAYSERVEAPISERVICE._serialized_start=8030 + _RAYSERVEAPISERVICE._serialized_end=8209 + _USERDEFINEDSERVICE._serialized_start=8212 + _USERDEFINEDSERVICE._serialized_end=8535 + _FRUITSERVICE._serialized_start=8537 + _FRUITSERVICE._serialized_end=8613 + _RAYSERVEBENCHMARKSERVICE._serialized_start=8616 + _RAYSERVEBENCHMARKSERVICE._serialized_end=8768 +# @@protoc_insertion_point(module_scope) diff --git a/lib/python3.12/site-packages/ray/serve/generated/serve_pb2_grpc.py b/lib/python3.12/site-packages/ray/serve/generated/serve_pb2_grpc.py new file mode 100644 index 0000000000000000000000000000000000000000..e0a0fbfd5beb60db0b27f1914bb396a55049dfb8 --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/generated/serve_pb2_grpc.py @@ -0,0 +1,414 @@ +# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! +"""Client and server classes corresponding to protobuf-defined services.""" +import grpc + +from . import serve_pb2 as src_dot_ray_dot_protobuf_dot_serve__pb2 + + +class RayServeAPIServiceStub(object): + """Missing associated documentation comment in .proto file.""" + + def __init__(self, channel): + """Constructor. + + Args: + channel: A grpc.Channel. + """ + self.ListApplications = channel.unary_unary( + '/ray.serve.RayServeAPIService/ListApplications', + request_serializer=src_dot_ray_dot_protobuf_dot_serve__pb2.ListApplicationsRequest.SerializeToString, + response_deserializer=src_dot_ray_dot_protobuf_dot_serve__pb2.ListApplicationsResponse.FromString, + ) + self.Healthz = channel.unary_unary( + '/ray.serve.RayServeAPIService/Healthz', + request_serializer=src_dot_ray_dot_protobuf_dot_serve__pb2.HealthzRequest.SerializeToString, + response_deserializer=src_dot_ray_dot_protobuf_dot_serve__pb2.HealthzResponse.FromString, + ) + + +class RayServeAPIServiceServicer(object): + """Missing associated documentation comment in .proto file.""" + + def ListApplications(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def Healthz(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + +def add_RayServeAPIServiceServicer_to_server(servicer, server): + rpc_method_handlers = { + 'ListApplications': grpc.unary_unary_rpc_method_handler( + servicer.ListApplications, + request_deserializer=src_dot_ray_dot_protobuf_dot_serve__pb2.ListApplicationsRequest.FromString, + response_serializer=src_dot_ray_dot_protobuf_dot_serve__pb2.ListApplicationsResponse.SerializeToString, + ), + 'Healthz': grpc.unary_unary_rpc_method_handler( + servicer.Healthz, + request_deserializer=src_dot_ray_dot_protobuf_dot_serve__pb2.HealthzRequest.FromString, + response_serializer=src_dot_ray_dot_protobuf_dot_serve__pb2.HealthzResponse.SerializeToString, + ), + } + generic_handler = grpc.method_handlers_generic_handler( + 'ray.serve.RayServeAPIService', rpc_method_handlers) + server.add_generic_rpc_handlers((generic_handler,)) + + + # This class is part of an EXPERIMENTAL API. +class RayServeAPIService(object): + """Missing associated documentation comment in .proto file.""" + + @staticmethod + def ListApplications(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary(request, target, '/ray.serve.RayServeAPIService/ListApplications', + src_dot_ray_dot_protobuf_dot_serve__pb2.ListApplicationsRequest.SerializeToString, + src_dot_ray_dot_protobuf_dot_serve__pb2.ListApplicationsResponse.FromString, + options, channel_credentials, + insecure, call_credentials, compression, wait_for_ready, timeout, metadata) + + @staticmethod + def Healthz(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary(request, target, '/ray.serve.RayServeAPIService/Healthz', + src_dot_ray_dot_protobuf_dot_serve__pb2.HealthzRequest.SerializeToString, + src_dot_ray_dot_protobuf_dot_serve__pb2.HealthzResponse.FromString, + options, channel_credentials, + insecure, call_credentials, compression, wait_for_ready, timeout, metadata) + + +class UserDefinedServiceStub(object): + """Missing associated documentation comment in .proto file.""" + + def __init__(self, channel): + """Constructor. + + Args: + channel: A grpc.Channel. + """ + self.__call__ = channel.unary_unary( + '/ray.serve.UserDefinedService/__call__', + request_serializer=src_dot_ray_dot_protobuf_dot_serve__pb2.UserDefinedMessage.SerializeToString, + response_deserializer=src_dot_ray_dot_protobuf_dot_serve__pb2.UserDefinedResponse.FromString, + ) + self.Method1 = channel.unary_unary( + '/ray.serve.UserDefinedService/Method1', + request_serializer=src_dot_ray_dot_protobuf_dot_serve__pb2.UserDefinedMessage.SerializeToString, + response_deserializer=src_dot_ray_dot_protobuf_dot_serve__pb2.UserDefinedResponse.FromString, + ) + self.Method2 = channel.unary_unary( + '/ray.serve.UserDefinedService/Method2', + request_serializer=src_dot_ray_dot_protobuf_dot_serve__pb2.UserDefinedMessage2.SerializeToString, + response_deserializer=src_dot_ray_dot_protobuf_dot_serve__pb2.UserDefinedResponse2.FromString, + ) + self.Streaming = channel.unary_stream( + '/ray.serve.UserDefinedService/Streaming', + request_serializer=src_dot_ray_dot_protobuf_dot_serve__pb2.UserDefinedMessage.SerializeToString, + response_deserializer=src_dot_ray_dot_protobuf_dot_serve__pb2.UserDefinedResponse.FromString, + ) + + +class UserDefinedServiceServicer(object): + """Missing associated documentation comment in .proto file.""" + + def __call__(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def Method1(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def Method2(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def Streaming(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + +def add_UserDefinedServiceServicer_to_server(servicer, server): + rpc_method_handlers = { + '__call__': grpc.unary_unary_rpc_method_handler( + servicer.__call__, + request_deserializer=src_dot_ray_dot_protobuf_dot_serve__pb2.UserDefinedMessage.FromString, + response_serializer=src_dot_ray_dot_protobuf_dot_serve__pb2.UserDefinedResponse.SerializeToString, + ), + 'Method1': grpc.unary_unary_rpc_method_handler( + servicer.Method1, + request_deserializer=src_dot_ray_dot_protobuf_dot_serve__pb2.UserDefinedMessage.FromString, + response_serializer=src_dot_ray_dot_protobuf_dot_serve__pb2.UserDefinedResponse.SerializeToString, + ), + 'Method2': grpc.unary_unary_rpc_method_handler( + servicer.Method2, + request_deserializer=src_dot_ray_dot_protobuf_dot_serve__pb2.UserDefinedMessage2.FromString, + response_serializer=src_dot_ray_dot_protobuf_dot_serve__pb2.UserDefinedResponse2.SerializeToString, + ), + 'Streaming': grpc.unary_stream_rpc_method_handler( + servicer.Streaming, + request_deserializer=src_dot_ray_dot_protobuf_dot_serve__pb2.UserDefinedMessage.FromString, + response_serializer=src_dot_ray_dot_protobuf_dot_serve__pb2.UserDefinedResponse.SerializeToString, + ), + } + generic_handler = grpc.method_handlers_generic_handler( + 'ray.serve.UserDefinedService', rpc_method_handlers) + server.add_generic_rpc_handlers((generic_handler,)) + + + # This class is part of an EXPERIMENTAL API. +class UserDefinedService(object): + """Missing associated documentation comment in .proto file.""" + + @staticmethod + def __call__(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary(request, target, '/ray.serve.UserDefinedService/__call__', + src_dot_ray_dot_protobuf_dot_serve__pb2.UserDefinedMessage.SerializeToString, + src_dot_ray_dot_protobuf_dot_serve__pb2.UserDefinedResponse.FromString, + options, channel_credentials, + insecure, call_credentials, compression, wait_for_ready, timeout, metadata) + + @staticmethod + def Method1(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary(request, target, '/ray.serve.UserDefinedService/Method1', + src_dot_ray_dot_protobuf_dot_serve__pb2.UserDefinedMessage.SerializeToString, + src_dot_ray_dot_protobuf_dot_serve__pb2.UserDefinedResponse.FromString, + options, channel_credentials, + insecure, call_credentials, compression, wait_for_ready, timeout, metadata) + + @staticmethod + def Method2(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary(request, target, '/ray.serve.UserDefinedService/Method2', + src_dot_ray_dot_protobuf_dot_serve__pb2.UserDefinedMessage2.SerializeToString, + src_dot_ray_dot_protobuf_dot_serve__pb2.UserDefinedResponse2.FromString, + options, channel_credentials, + insecure, call_credentials, compression, wait_for_ready, timeout, metadata) + + @staticmethod + def Streaming(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_stream(request, target, '/ray.serve.UserDefinedService/Streaming', + src_dot_ray_dot_protobuf_dot_serve__pb2.UserDefinedMessage.SerializeToString, + src_dot_ray_dot_protobuf_dot_serve__pb2.UserDefinedResponse.FromString, + options, channel_credentials, + insecure, call_credentials, compression, wait_for_ready, timeout, metadata) + + +class FruitServiceStub(object): + """Missing associated documentation comment in .proto file.""" + + def __init__(self, channel): + """Constructor. + + Args: + channel: A grpc.Channel. + """ + self.FruitStand = channel.unary_unary( + '/ray.serve.FruitService/FruitStand', + request_serializer=src_dot_ray_dot_protobuf_dot_serve__pb2.FruitAmounts.SerializeToString, + response_deserializer=src_dot_ray_dot_protobuf_dot_serve__pb2.FruitCosts.FromString, + ) + + +class FruitServiceServicer(object): + """Missing associated documentation comment in .proto file.""" + + def FruitStand(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + +def add_FruitServiceServicer_to_server(servicer, server): + rpc_method_handlers = { + 'FruitStand': grpc.unary_unary_rpc_method_handler( + servicer.FruitStand, + request_deserializer=src_dot_ray_dot_protobuf_dot_serve__pb2.FruitAmounts.FromString, + response_serializer=src_dot_ray_dot_protobuf_dot_serve__pb2.FruitCosts.SerializeToString, + ), + } + generic_handler = grpc.method_handlers_generic_handler( + 'ray.serve.FruitService', rpc_method_handlers) + server.add_generic_rpc_handlers((generic_handler,)) + + + # This class is part of an EXPERIMENTAL API. +class FruitService(object): + """Missing associated documentation comment in .proto file.""" + + @staticmethod + def FruitStand(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary(request, target, '/ray.serve.FruitService/FruitStand', + src_dot_ray_dot_protobuf_dot_serve__pb2.FruitAmounts.SerializeToString, + src_dot_ray_dot_protobuf_dot_serve__pb2.FruitCosts.FromString, + options, channel_credentials, + insecure, call_credentials, compression, wait_for_ready, timeout, metadata) + + +class RayServeBenchmarkServiceStub(object): + """Missing associated documentation comment in .proto file.""" + + def __init__(self, channel): + """Constructor. + + Args: + channel: A grpc.Channel. + """ + self.grpc_call = channel.unary_unary( + '/ray.serve.RayServeBenchmarkService/grpc_call', + request_serializer=src_dot_ray_dot_protobuf_dot_serve__pb2.ArrayData.SerializeToString, + response_deserializer=src_dot_ray_dot_protobuf_dot_serve__pb2.ModelOutput.FromString, + ) + self.call_with_string = channel.unary_unary( + '/ray.serve.RayServeBenchmarkService/call_with_string', + request_serializer=src_dot_ray_dot_protobuf_dot_serve__pb2.StringData.SerializeToString, + response_deserializer=src_dot_ray_dot_protobuf_dot_serve__pb2.ModelOutput.FromString, + ) + + +class RayServeBenchmarkServiceServicer(object): + """Missing associated documentation comment in .proto file.""" + + def grpc_call(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def call_with_string(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + +def add_RayServeBenchmarkServiceServicer_to_server(servicer, server): + rpc_method_handlers = { + 'grpc_call': grpc.unary_unary_rpc_method_handler( + servicer.grpc_call, + request_deserializer=src_dot_ray_dot_protobuf_dot_serve__pb2.ArrayData.FromString, + response_serializer=src_dot_ray_dot_protobuf_dot_serve__pb2.ModelOutput.SerializeToString, + ), + 'call_with_string': grpc.unary_unary_rpc_method_handler( + servicer.call_with_string, + request_deserializer=src_dot_ray_dot_protobuf_dot_serve__pb2.StringData.FromString, + response_serializer=src_dot_ray_dot_protobuf_dot_serve__pb2.ModelOutput.SerializeToString, + ), + } + generic_handler = grpc.method_handlers_generic_handler( + 'ray.serve.RayServeBenchmarkService', rpc_method_handlers) + server.add_generic_rpc_handlers((generic_handler,)) + + + # This class is part of an EXPERIMENTAL API. +class RayServeBenchmarkService(object): + """Missing associated documentation comment in .proto file.""" + + @staticmethod + def grpc_call(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary(request, target, '/ray.serve.RayServeBenchmarkService/grpc_call', + src_dot_ray_dot_protobuf_dot_serve__pb2.ArrayData.SerializeToString, + src_dot_ray_dot_protobuf_dot_serve__pb2.ModelOutput.FromString, + options, channel_credentials, + insecure, call_credentials, compression, wait_for_ready, timeout, metadata) + + @staticmethod + def call_with_string(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary(request, target, '/ray.serve.RayServeBenchmarkService/call_with_string', + src_dot_ray_dot_protobuf_dot_serve__pb2.StringData.SerializeToString, + src_dot_ray_dot_protobuf_dot_serve__pb2.ModelOutput.FromString, + options, channel_credentials, + insecure, call_credentials, compression, wait_for_ready, timeout, metadata) diff --git a/lib/python3.12/site-packages/ray/serve/gradio_integrations.py b/lib/python3.12/site-packages/ray/serve/gradio_integrations.py new file mode 100644 index 0000000000000000000000000000000000000000..dc8960d78c8b41da612a2e35e41310d729e53350 --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/gradio_integrations.py @@ -0,0 +1,32 @@ +import logging +from typing import Callable + +from ray import serve +from ray.serve._private.constants import SERVE_LOGGER_NAME +from ray.serve._private.http_util import ASGIAppReplicaWrapper +from ray.util.annotations import PublicAPI + +try: + from gradio import Blocks, routes +except ModuleNotFoundError: + print("Gradio isn't installed. Run `pip install gradio` to install Gradio.") + raise + +logger = logging.getLogger(SERVE_LOGGER_NAME) + + +@PublicAPI(stability="alpha") +class GradioIngress(ASGIAppReplicaWrapper): + """User-facing class that wraps a Gradio App in a Serve Deployment.""" + + def __init__(self, builder: Callable[[], Blocks]): + """Builds and wraps an ASGI app from the provided builder. + + The builder should take no arguments and return a Gradio App (of type Interface + or Blocks). + """ + io: Blocks = builder() + super().__init__(routes.App.create_app(io)) + + +GradioServer = serve.deployment(GradioIngress) diff --git a/lib/python3.12/site-packages/ray/serve/handle.py b/lib/python3.12/site-packages/ray/serve/handle.py new file mode 100644 index 0000000000000000000000000000000000000000..f5ecf3661d8390af4c6dabc04d46bd48bac2b08d --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/handle.py @@ -0,0 +1,809 @@ +import asyncio +import concurrent.futures +import logging +import time +import warnings +from typing import Any, AsyncIterator, Dict, Iterator, Optional, Tuple, Union + +import ray +from ray import serve +from ray._raylet import ObjectRefGenerator +from ray.serve._private.common import ( + OBJ_REF_NOT_SUPPORTED_ERROR, + DeploymentHandleSource, + DeploymentID, + RequestMetadata, +) +from ray.serve._private.constants import SERVE_LOGGER_NAME +from ray.serve._private.default_impl import ( + CreateRouterCallable, + create_dynamic_handle_options, + create_init_handle_options, + create_router, +) +from ray.serve._private.handle_options import ( + DynamicHandleOptionsBase, + InitHandleOptionsBase, +) +from ray.serve._private.replica_result import ReplicaResult +from ray.serve._private.router import Router +from ray.serve._private.usage import ServeUsageTag +from ray.serve._private.utils import ( + DEFAULT, + calculate_remaining_timeout, + get_random_string, + inside_ray_client_context, + is_running_in_asyncio_loop, +) +from ray.serve.exceptions import RayServeException, RequestCancelledError +from ray.util import metrics +from ray.util.annotations import DeveloperAPI, PublicAPI + +logger = logging.getLogger(SERVE_LOGGER_NAME) + + +class _DeploymentHandleBase: + def __init__( + self, + deployment_name: str, + app_name: str, + *, + init_options: Optional[InitHandleOptionsBase] = None, + handle_options: Optional[DynamicHandleOptionsBase] = None, + _router: Optional[Router] = None, + _create_router: Optional[CreateRouterCallable] = None, + _request_counter: Optional[metrics.Counter] = None, + _handle_id: Optional[str] = None, + ): + self.deployment_id = DeploymentID(name=deployment_name, app_name=app_name) + self.init_options: Optional[InitHandleOptionsBase] = init_options + self.handle_options: DynamicHandleOptionsBase = ( + handle_options or create_dynamic_handle_options() + ) + + # Handle ID is shared among handles that are returned by + # `handle.options` or `handle.method` + self.handle_id = _handle_id or get_random_string() + self.request_counter = _request_counter or self._create_request_counter( + app_name, deployment_name, self.handle_id + ) + + self._router: Optional[Router] = _router + if _create_router is None: + self._create_router = create_router + else: + self._create_router = _create_router + + @staticmethod + def _gen_handle_tag(app_name: str, deployment_name: str, handle_id: str): + if app_name: + return f"{app_name}#{deployment_name}#{handle_id}" + else: + return f"{deployment_name}#{handle_id}" + + @classmethod + def _create_request_counter( + cls, app_name: str, deployment_name: str, handle_id: str + ): + return metrics.Counter( + "serve_handle_request_counter", + description=( + "The number of handle.remote() calls that have been " + "made on this handle." + ), + tag_keys=("handle", "deployment", "route", "application"), + ).set_default_tags( + { + "handle": cls._gen_handle_tag( + app_name, deployment_name, handle_id=handle_id + ), + "deployment": deployment_name, + "application": app_name, + } + ) + + def running_replicas_populated(self) -> bool: + if self._router is None: + return False + + return self._router.running_replicas_populated() + + @property + def deployment_name(self) -> str: + return self.deployment_id.name + + @property + def app_name(self) -> str: + return self.deployment_id.app_name + + @property + def is_initialized(self) -> bool: + return self._router is not None + + def _init(self, **kwargs): + """Initialize this handle with arguments. + + A handle can only be initialized once. A handle is implicitly + initialized when `.options()` or `.remote()` is called. Therefore + to initialize a handle with custom init options, you must do it + before calling `.options()` or `.remote()`. + """ + if self._router is not None: + raise RuntimeError( + "Handle has already been initialized. Note that a handle is implicitly " + "initialized when you call `.options()` or `.remote()`. You either " + "tried to call `._init()` twice or called `._init()` after calling " + "`.options()` or `.remote()`. If you want to modify the init options, " + "please do so before calling `.options()` or `.remote()`. This handle " + f"was initialized with {self.init_options}." + ) + + init_options = create_init_handle_options(**kwargs) + self._router = self._create_router( + handle_id=self.handle_id, + deployment_id=self.deployment_id, + handle_options=init_options, + ) + self.init_options = init_options + + logger.info( + f"Initialized DeploymentHandle {self.handle_id} for {self.deployment_id}.", + extra={"log_to_stderr": False}, + ) + + # Record handle api telemetry when not in the proxy + if ( + self.init_options._source != DeploymentHandleSource.PROXY + and self.__class__ == DeploymentHandle + ): + ServeUsageTag.DEPLOYMENT_HANDLE_API_USED.record("1") + + def _is_router_running_in_separate_loop(self) -> bool: + return self.init_options._run_router_in_separate_loop + + def _options(self, _prefer_local_routing=DEFAULT.VALUE, **kwargs): + if kwargs.get("stream") is True and inside_ray_client_context(): + raise RuntimeError( + "Streaming DeploymentHandles are not currently supported when " + "connected to a remote Ray cluster using Ray Client." + ) + + new_handle_options = self.handle_options.copy_and_update(**kwargs) + + # TODO(zcin): remove when _prefer_local_routing is removed from options() path + if _prefer_local_routing != DEFAULT.VALUE: + self._init(_prefer_local_routing=_prefer_local_routing) + + if not self.is_initialized: + self._init() + + return DeploymentHandle( + self.deployment_name, + self.app_name, + init_options=self.init_options, + handle_options=new_handle_options, + _router=self._router, + _create_router=self._create_router, + _request_counter=self.request_counter, + _handle_id=self.handle_id, + ) + + def _remote( + self, + args: Tuple[Any], + kwargs: Dict[str, Any], + ) -> Tuple[concurrent.futures.Future, RequestMetadata]: + if not self.is_initialized: + self._init() + + metadata = serve._private.default_impl.get_request_metadata( + self.init_options, self.handle_options + ) + + self.request_counter.inc( + tags={ + "route": metadata.route, + "application": metadata.app_name, + } + ) + + return self._router.assign_request(metadata, *args, **kwargs), metadata + + def __getattr__(self, name): + return self.options(method_name=name) + + def shutdown(self): + if self._router: + shutdown_future = self._router.shutdown() + if self._is_router_running_in_separate_loop(): + shutdown_future.result() + else: + logger.warning( + "Synchronously shutting down a router that's running in the same " + "event loop can only be done best effort. Please use " + "`shutdown_async` instead." + ) + + async def shutdown_async(self): + if self._router: + shutdown_future: Union[ + asyncio.Future, concurrent.futures.Future + ] = self._router.shutdown() + if self._is_router_running_in_separate_loop: + await asyncio.wrap_future(shutdown_future) + else: + await shutdown_future + + def __repr__(self): + return f"{self.__class__.__name__}" f"(deployment='{self.deployment_name}')" + + @classmethod + def _deserialize(cls, kwargs): + """Required for this class's __reduce__ method to be picklable.""" + return cls(**kwargs) + + def __reduce__(self): + serialized_constructor_args = { + "deployment_name": self.deployment_name, + "app_name": self.app_name, + "handle_options": self.handle_options, + } + return self.__class__._deserialize, (serialized_constructor_args,) + + +class _DeploymentResponseBase: + def __init__( + self, + replica_result_future: Union[ + concurrent.futures.Future[ReplicaResult], asyncio.Future[ReplicaResult] + ], + request_metadata: RequestMetadata, + _is_router_running_in_separate_loop: bool = True, + ): + self._cancelled = False + self._replica_result_future = replica_result_future + self._replica_result: Optional[ReplicaResult] = None + self._request_metadata: RequestMetadata = request_metadata + self._is_router_running_in_separate_loop = _is_router_running_in_separate_loop + + @property + def request_id(self) -> str: + return self._request_metadata.request_id + + @property + def by_reference(self) -> bool: + return self._request_metadata._by_reference + + def _fetch_future_result_sync( + self, _timeout_s: Optional[float] = None + ) -> ReplicaResult: + """Synchronously fetch the replica result. + + The result is cached in `self._replica_result`. + """ + + if self._replica_result is None: + if not self._is_router_running_in_separate_loop: + raise RuntimeError( + "Sync methods should not be called from within an `asyncio` event " + "loop. Use `await response` instead." + ) + try: + self._replica_result = self._replica_result_future.result( + timeout=_timeout_s + ) + + except concurrent.futures.TimeoutError: + raise TimeoutError("Timed out resolving to ObjectRef.") from None + except concurrent.futures.CancelledError: + raise RequestCancelledError(self.request_id) from None + + return self._replica_result + + async def _fetch_future_result_async(self) -> ReplicaResult: + """Asynchronously fetch replica result. + + The result is cached in `self._replica_result`.. + """ + + if self._replica_result is None: + if self._is_router_running_in_separate_loop: + # Use `asyncio.wrap_future` so `self._replica_result_future` can be awaited + # safely from any asyncio loop. + # self._replica_result_future is a object of type concurrent.futures.Future + self._replica_result = await asyncio.wrap_future( + self._replica_result_future + ) + else: + # self._replica_result_future is a object of type asyncio.Future + self._replica_result = await self._replica_result_future + + return self._replica_result + + def cancel(self): + """Attempt to cancel the `DeploymentHandle` call. + + This is best effort. + + - If the request hasn't been assigned to a replica, the assignment will be + cancelled. + - If the request has been assigned to a replica, `ray.cancel` will be + called on the object ref, attempting to cancel the request and any downstream + requests it makes. + + If the request is successfully cancelled, subsequent operations on the ref will + raise an exception: + + - If the request was cancelled before assignment, they'll raise + `asyncio.CancelledError` (or a `concurrent.futures.CancelledError` for + synchronous methods like `.result()`.). + - If the request was cancelled after assignment, they'll raise + `ray.exceptions.TaskCancelledError`. + """ + if self._cancelled: + return + + self._cancelled = True + self._replica_result_future.cancel() + if not self._is_router_running_in_separate_loop: + # Given that there is a event loop running, we can't call sync methods. + # Hence optimistically cancel the replica result future and replica result. + if self._replica_result: + self._replica_result.cancel() + return + try: + # try to fetch the results synchronously. if it succeeds, + # we will explicitly cancel the replica result. if it fails, + # the request is already cancelled and we can return early. + self._fetch_future_result_sync() + except RequestCancelledError: + # request is already cancelled nothing to do here + return + self._replica_result.cancel() + + @DeveloperAPI + def cancelled(self) -> bool: + """Whether or not the request has been cancelled. + + This is `True` if `.cancel()` is called, but the request may actually have run + to completion. + """ + return self._cancelled + + +@PublicAPI(stability="stable") +class DeploymentResponse(_DeploymentResponseBase): + """A future-like object wrapping the result of a unary deployment handle call. + + From inside a deployment, a `DeploymentResponse` can be awaited to retrieve the + output of the call without blocking the asyncio event loop. + + From outside a deployment, `.result()` can be used to retrieve the output in a + blocking manner. + + Example: + + .. code-block:: python + + from ray import serve + from ray.serve.handle import DeploymentHandle + + @serve.deployment + class Downstream: + def say_hi(self, message: str) -> str: + return f"Hello {message}!" + + @serve.deployment + class Caller: + def __init__(self, handle: DeploymentHandle): + self._downstream_handle = handle + + async def __call__(self, message: str) -> str: + # Inside a deployment: `await` the result to enable concurrency. + response = self._downstream_handle.say_hi.remote(message) + return await response + + app = Caller.bind(Downstream.bind()) + handle: DeploymentHandle = serve.run(app) + + # Outside a deployment: call `.result()` to get output. + response = handle.remote("world") + assert response.result() == "Hello world!" + + A `DeploymentResponse` can be passed directly to another `DeploymentHandle` call + without fetching the result to enable composing multiple deployments together. + + Example: + + .. code-block:: python + + from ray import serve + from ray.serve.handle import DeploymentHandle + + @serve.deployment + class Adder: + def add(self, val: int) -> int: + return val + 1 + + @serve.deployment + class Caller: + def __init__(self, handle: DeploymentHandle): + self._adder_handle = handle + + async def __call__(self, start: int) -> int: + return await self._adder_handle.add.remote( + # Pass the response directly to another handle call without awaiting. + self._adder_handle.add.remote(start) + ) + + app = Caller.bind(Adder.bind()) + handle: DeploymentHandle = serve.run(app) + assert handle.remote(0).result() == 2 + """ + + def __await__(self): + """Yields the final result of the deployment handle call.""" + try: + replica_result = yield from self._fetch_future_result_async().__await__() + result = yield from replica_result.get_async().__await__() + return result + except asyncio.CancelledError: + if self._cancelled: + raise RequestCancelledError(self.request_id) from None + else: + raise asyncio.CancelledError from None + + def __reduce__(self): + raise RayServeException( + "`DeploymentResponse` is not serializable. If you are passing the " + "`DeploymentResponse` in a nested object (e.g. a list or dictionary) to a " + "downstream deployment handle call, that is no longer supported. Please " + "only pass `DeploymentResponse` objects as top level arguments." + ) + + def result( + self, + *, + timeout_s: Optional[float] = None, + _skip_asyncio_check: bool = False, + ) -> Any: + """Fetch the result of the handle call synchronously. + + This should *not* be used from within a deployment as it runs in an asyncio + event loop. For model composition, `await` the response instead. + + If `timeout_s` is provided and the result is not available before the timeout, + a `TimeoutError` is raised. + """ + + if not _skip_asyncio_check and is_running_in_asyncio_loop(): + raise RuntimeError( + "Sync methods should not be called from within an `asyncio` event " + "loop. Use `await response` instead." + ) + + start_time_s = time.time() + replica_result = self._fetch_future_result_sync(timeout_s) + + remaining_timeout_s = calculate_remaining_timeout( + timeout_s=timeout_s, start_time_s=start_time_s, curr_time_s=time.time() + ) + return replica_result.get(remaining_timeout_s) + + @DeveloperAPI + async def _to_object_ref(self) -> ray.ObjectRef: + """Advanced API to convert the response to a Ray `ObjectRef`. + + This is used to pass the output of a `DeploymentHandle` call to a Ray task or + actor method call. + + This method is `async def` because it will block until the handle call has been + assigned to a replica. If there are many requests in flight and all + replicas' queues are full, this may be a slow operation. + """ + + ServeUsageTag.DEPLOYMENT_HANDLE_TO_OBJECT_REF_API_USED.record("1") + + if not self._request_metadata._by_reference: + raise OBJ_REF_NOT_SUPPORTED_ERROR + + replica_result = await self._fetch_future_result_async() + return await replica_result.to_object_ref_async() + + @DeveloperAPI + def _to_object_ref_sync( + self, + _timeout_s: Optional[float] = None, + _allow_running_in_asyncio_loop: bool = False, + ) -> ray.ObjectRef: + """Advanced API to convert the response to a Ray `ObjectRef`. + + This is used to pass the output of a `DeploymentHandle` call to a Ray task or + actor method call. + + This method is a *blocking* call because it will block until the handle call has + been assigned to a replica. If there are many requests in flight and all + replicas' queues are full, this may be a slow operation. + + From inside a deployment, `_to_object_ref` should be used instead to avoid + blocking the asyncio event loop. + """ + + ServeUsageTag.DEPLOYMENT_HANDLE_TO_OBJECT_REF_API_USED.record("1") + + if not self._request_metadata._by_reference: + raise OBJ_REF_NOT_SUPPORTED_ERROR + + if not _allow_running_in_asyncio_loop and is_running_in_asyncio_loop(): + raise RuntimeError( + "Sync methods should not be called from within an `asyncio` event " + "loop. Use `await response._to_object_ref()` instead." + ) + + # First, fetch the result of the future + start_time_s = time.time() + replica_result = self._fetch_future_result_sync(_timeout_s) + + # Then, if necessary, resolve generator to ref + remaining_timeout_s = calculate_remaining_timeout( + timeout_s=_timeout_s, + start_time_s=start_time_s, + curr_time_s=time.time(), + ) + return replica_result.to_object_ref(timeout_s=remaining_timeout_s) + + +@PublicAPI(stability="stable") +class DeploymentResponseGenerator(_DeploymentResponseBase): + """A future-like object wrapping the result of a streaming deployment handle call. + + This is returned when using `handle.options(stream=True)` and calling a generator + deployment method. + + `DeploymentResponseGenerator` is both a synchronous and asynchronous iterator. + + When iterating over results from inside a deployment, `async for` should be used to + avoid blocking the asyncio event loop. + + When iterating over results from outside a deployment, use a standard `for` loop. + + Example: + + .. code-block:: python + + from typing import AsyncGenerator, Generator + + from ray import serve + from ray.serve.handle import DeploymentHandle + + @serve.deployment + class Streamer: + def generate_numbers(self, limit: int) -> Generator[int]: + for i in range(limit): + yield i + + @serve.deployment + class Caller: + def __init__(self, handle: DeploymentHandle): + # Set `stream=True` on the handle to enable streaming calls. + self._streaming_handle = handle.options(stream=True) + + async def __call__(self, limit: int) -> AsyncIterator[int]: + gen: DeploymentResponseGenerator = ( + self._streaming_handle.generate_numbers.remote(limit) + ) + + # Inside a deployment: use `async for` to enable concurrency. + async for i in gen: + yield i + + app = Caller.bind(Streamer.bind()) + handle: DeploymentHandle = serve.run(app) + + # Outside a deployment: use a standard `for` loop. + gen: DeploymentResponseGenerator = handle.options(stream=True).remote(10) + assert [i for i in gen] == list(range(10)) + + A `DeploymentResponseGenerator` *cannot* currently be passed to another + `DeploymentHandle` call. + """ + + def __await__(self): + raise TypeError( + "`DeploymentResponseGenerator` cannot be awaited directly. Use `async for` " + "or `await response.__anext__() instead`." + ) + + def __aiter__(self) -> AsyncIterator[Any]: + return self + + async def __anext__(self) -> Any: + try: + replica_result = await self._fetch_future_result_async() + return await replica_result.__anext__() + except asyncio.CancelledError: + if self._cancelled: + raise RequestCancelledError(self.request_id) from None + else: + raise asyncio.CancelledError from None + + def __iter__(self) -> Iterator[Any]: + return self + + def __next__(self) -> Any: + if is_running_in_asyncio_loop(): + raise RuntimeError( + "Sync methods should not be called from within an `asyncio` event " + "loop. Use `async for` or `await response.__anext__()` instead." + ) + + replica_result = self._fetch_future_result_sync() + return replica_result.__next__() + + @DeveloperAPI + async def _to_object_ref_gen(self) -> ObjectRefGenerator: + """Advanced API to convert the generator to a Ray `ObjectRefGenerator`. + + This method is `async def` because it will block until the handle call has been + assigned to a replica. If there are many requests in flight and all + replicas' queues are full, this may be a slow operation. + """ + + ServeUsageTag.DEPLOYMENT_HANDLE_TO_OBJECT_REF_API_USED.record("1") + + if not self._request_metadata._by_reference: + raise OBJ_REF_NOT_SUPPORTED_ERROR + + replica_result = await self._fetch_future_result_async() + return replica_result.to_object_ref_gen() + + @DeveloperAPI + def _to_object_ref_gen_sync( + self, + _timeout_s: Optional[float] = None, + _allow_running_in_asyncio_loop: bool = False, + ) -> ObjectRefGenerator: + """Advanced API to convert the generator to a Ray `ObjectRefGenerator`. + + This method is a *blocking* call because it will block until the handle call has + been assigned to a replica. If there are many requests in flight and all + replicas' queues are full, this may be a slow operation. + + From inside a deployment, `_to_object_ref_gen` should be used instead to avoid + blocking the asyncio event loop. + """ + + ServeUsageTag.DEPLOYMENT_HANDLE_TO_OBJECT_REF_API_USED.record("1") + + if not self._request_metadata._by_reference: + raise OBJ_REF_NOT_SUPPORTED_ERROR + + if not _allow_running_in_asyncio_loop and is_running_in_asyncio_loop(): + raise RuntimeError( + "Sync methods should not be called from within an `asyncio` event " + "loop. Use `await response._to_object_ref()` instead." + ) + + replica_result = self._fetch_future_result_sync(_timeout_s) + return replica_result.to_object_ref_gen() + + +@PublicAPI(stability="stable") +class DeploymentHandle(_DeploymentHandleBase): + """A handle used to make requests to a deployment at runtime. + + This is primarily used to compose multiple deployments within a single application. + It can also be used to make calls to the ingress deployment of an application (e.g., + for programmatic testing). + + Example: + + + .. code-block:: python + + import ray + from ray import serve + from ray.serve.handle import DeploymentHandle, DeploymentResponse + + @serve.deployment + class Downstream: + def say_hi(self, message: str): + return f"Hello {message}!" + self._message = message + + @serve.deployment + class Ingress: + def __init__(self, handle: DeploymentHandle): + self._downstream_handle = handle + + async def __call__(self, name: str) -> str: + response = self._downstream_handle.say_hi.remote(name) + return await response + + app = Ingress.bind(Downstream.bind()) + handle: DeploymentHandle = serve.run(app) + response = handle.remote("world") + assert response.result() == "Hello world!" + """ + + def options( + self, + *, + method_name: Union[str, DEFAULT] = DEFAULT.VALUE, + multiplexed_model_id: Union[str, DEFAULT] = DEFAULT.VALUE, + stream: Union[bool, DEFAULT] = DEFAULT.VALUE, + use_new_handle_api: Union[bool, DEFAULT] = DEFAULT.VALUE, + _prefer_local_routing: Union[bool, DEFAULT] = DEFAULT.VALUE, + ) -> "DeploymentHandle": + """Set options for this handle and return an updated copy of it. + + Example: + + .. code-block:: python + + response: DeploymentResponse = handle.options( + method_name="other_method", + multiplexed_model_id="model:v1", + ).remote() + """ + if use_new_handle_api is not DEFAULT.VALUE: + warnings.warn( + "Setting `use_new_handle_api` no longer has any effect. " + "This argument will be removed in a future version." + ) + + if _prefer_local_routing is not DEFAULT.VALUE: + warnings.warn( + "Modifying `_prefer_local_routing` with `options()` is " + "deprecated. Please use `init()` instead." + ) + + return self._options( + method_name=method_name, + multiplexed_model_id=multiplexed_model_id, + stream=stream, + _prefer_local_routing=_prefer_local_routing, + ) + + def remote( + self, *args, **kwargs + ) -> Union[DeploymentResponse, DeploymentResponseGenerator]: + """Issue a remote call to a method of the deployment. + + By default, the result is a `DeploymentResponse` that can be awaited to fetch + the result of the call or passed to another `.remote()` call to compose multiple + deployments. + + If `handle.options(stream=True)` is set and a generator method is called, this + returns a `DeploymentResponseGenerator` instead. + + Example: + + .. code-block:: python + + # Fetch the result directly. + response = handle.remote() + result = await response + + # Pass the result to another handle call. + composed_response = handle2.remote(handle1.remote()) + composed_result = await composed_response + + Args: + *args: Positional arguments to be serialized and passed to the + remote method call. + **kwargs: Keyword arguments to be serialized and passed to the + remote method call. + """ + + future, request_metadata = self._remote(args, kwargs) + if self.handle_options.stream: + response_cls = DeploymentResponseGenerator + else: + response_cls = DeploymentResponse + + return response_cls( + future, + request_metadata, + _is_router_running_in_separate_loop=self._is_router_running_in_separate_loop(), + ) diff --git a/lib/python3.12/site-packages/ray/serve/llm/__init__.py b/lib/python3.12/site-packages/ray/serve/llm/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..4b829a6eb2945007ab0229315405746661a3f96b --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/llm/__init__.py @@ -0,0 +1,433 @@ +from typing import TYPE_CHECKING, Optional, Type + +from ray._common.deprecation import Deprecated +from ray.llm._internal.serve.core.configs.llm_config import ( + CloudMirrorConfig as _CloudMirrorConfig, + LLMConfig as _LLMConfig, + LoraConfig as _LoraConfig, + ModelLoadingConfig as _ModelLoadingConfig, +) +from ray.llm._internal.serve.core.ingress.builder import ( + LLMServingArgs as _LLMServingArgs, +) +from ray.llm._internal.serve.core.ingress.ingress import ( + OpenAiIngress as _OpenAiIngress, +) + +# For backward compatibility +from ray.llm._internal.serve.core.server.llm_server import ( + LLMServer as _LLMServer, +) +from ray.util.annotations import PublicAPI + +if TYPE_CHECKING: + from ray.serve.deployment import Application + + +########## +# Models +########## + + +@PublicAPI(stability="alpha") +class LLMConfig(_LLMConfig): + """The configuration for starting an LLM deployment.""" + + pass + + +@PublicAPI(stability="alpha") +class LLMServingArgs(_LLMServingArgs): + """The configuration for starting an LLM deployment application.""" + + pass + + +@PublicAPI(stability="alpha") +class ModelLoadingConfig(_ModelLoadingConfig): + """The configuration for loading an LLM model.""" + + pass + + +@PublicAPI(stability="alpha") +class CloudMirrorConfig(_CloudMirrorConfig): + """The configuration for mirroring an LLM model from cloud storage.""" + + pass + + +@PublicAPI(stability="alpha") +class LoraConfig(_LoraConfig): + """The configuration for loading an LLM model with LoRA.""" + + pass + + +############# +# Deployments +############# + + +@Deprecated( + old="ray.serve.llm.LLMServer", new="ray.serve.llm.deployment.LLMServer", error=False +) +class LLMServer(_LLMServer): + pass + + +@Deprecated( + old="ray.serve.llm.LLMRouter", + new="ray.serve.llm.ingress.OpenAIIngress", + error=False, +) +class LLMRouter(_OpenAiIngress): + pass + + +########## +# Builders +########## + + +@PublicAPI(stability="alpha") +def build_llm_deployment( + llm_config: "LLMConfig", + *, + name_prefix: Optional[str] = None, + bind_kwargs: Optional[dict] = None, + override_serve_options: Optional[dict] = None, + deployment_cls: Optional[Type[LLMServer]] = None, +) -> "Application": + """Helper to build a single vllm deployment from the given llm config. + + Examples: + .. testcode:: + :skipif: True + + from ray import serve + from ray.serve.llm import LLMConfig, build_llm_deployment + + # Configure the model + llm_config = LLMConfig( + model_loading_config=dict( + model_id="llama-3.1-8b", + model_source="meta-llama/Llama-3.1-8b-instruct", + ), + deployment_config=dict( + autoscaling_config=dict( + min_replicas=1, + max_replicas=2, + ) + ), + accelerator_type="A10G", + ) + + # Build the deployment + llm_app = build_llm_deployment(llm_config) + + # Deploy the application + model_handle = serve.run(llm_app) + + # Querying the model handle + import asyncio + model_handle = model_handle.options(stream=True) + async def query_model(model_handle): + from ray.serve.llm.openai_api_models import ChatCompletionRequest + + request = ChatCompletionRequest( + model="qwen-0.5b", + messages=[ + { + "role": "user", + "content": "Hello, world!" + } + ] + ) + + resp = model_handle.chat.remote(request) + async for message in resp: + print("message: ", message) + + asyncio.run(query_model(model_handle)) + + Args: + llm_config: The llm config to build vllm deployment. + name_prefix: Optional prefix to be used for the deployment name. + bind_kwargs: Optional kwargs to pass to the deployment. + override_serve_options: Optional serve options to override the original serve options based on the llm_config. + deployment_cls: Optional deployment class to use. + + Returns: + The configured Ray Serve Application for vllm deployment. + """ + from ray.llm._internal.serve.core.server.builder import ( + build_llm_deployment, + ) + + return build_llm_deployment( + llm_config=llm_config, + name_prefix=name_prefix, + bind_kwargs=bind_kwargs, + override_serve_options=override_serve_options, + deployment_cls=deployment_cls, + ) + + +@PublicAPI(stability="alpha") +def build_openai_app(llm_serving_args: dict) -> "Application": + """Helper to build an OpenAI compatible app with the llm deployment setup from + the given llm serving args. This is the main entry point for users to create a + Serve application serving LLMs. + + + Examples: + .. code-block:: python + :caption: Example usage in code. + + from ray import serve + from ray.serve.llm import LLMConfig, LLMServingArgs, build_openai_app + + llm_config1 = LLMConfig( + model_loading_config=dict( + model_id="qwen-0.5b", + model_source="Qwen/Qwen2.5-0.5B-Instruct", + ), + deployment_config=dict( + autoscaling_config=dict( + min_replicas=1, max_replicas=2, + ) + ), + accelerator_type="A10G", + ) + + llm_config2 = LLMConfig( + model_loading_config=dict( + model_id="qwen-1.5b", + model_source="Qwen/Qwen2.5-1.5B-Instruct", + ), + deployment_config=dict( + autoscaling_config=dict( + min_replicas=1, max_replicas=2, + ) + ), + accelerator_type="A10G", + ) + + # Deploy the application + llm_app = build_openai_app( + LLMServingArgs( + llm_configs=[ + llm_config1, + llm_config2, + ] + ) + ) + serve.run(llm_app) + + + # Querying the model via openai client + from openai import OpenAI + + # Initialize client + client = OpenAI(base_url="http://localhost:8000/v1", api_key="fake-key") + + # Basic completion + response = client.chat.completions.create( + model="qwen-0.5b", + messages=[{"role": "user", "content": "Hello!"}] + ) + + .. code-block:: yaml + :caption: Example usage in YAML. + + # config.yaml + applications: + - args: + llm_configs: + - model_loading_config: + model_id: qwen-0.5b + model_source: Qwen/Qwen2.5-0.5B-Instruct + accelerator_type: A10G + deployment_config: + autoscaling_config: + min_replicas: 1 + max_replicas: 2 + - model_loading_config: + model_id: qwen-1.5b + model_source: Qwen/Qwen2.5-1.5B-Instruct + accelerator_type: A10G + deployment_config: + autoscaling_config: + min_replicas: 1 + max_replicas: 2 + import_path: ray.serve.llm:build_openai_app + name: llm_app + route_prefix: "/" + + + Args: + llm_serving_args: A dict that conforms to the LLMServingArgs pydantic model. + + Returns: + The configured Ray Serve Application router. + """ + from ray.llm._internal.serve.core.ingress.builder import ( + build_openai_app, + ) + + return build_openai_app(builder_config=llm_serving_args) + + +@PublicAPI(stability="alpha") +def build_pd_openai_app(pd_serving_args: dict) -> "Application": + """Build a deployable application utilizing P/D disaggregation. + + + Examples: + .. code-block:: python + :caption: Example usage in code. + + from ray import serve + from ray.serve.llm import LLMConfig, build_pd_openai_app + + config = LLMConfig( + model_loading_config=dict( + model_id="qwen-0.5b", + model_source="Qwen/Qwen2.5-0.5B-Instruct", + ), + deployment_config=dict( + autoscaling_config=dict( + min_replicas=1, max_replicas=2, + ) + ), + accelerator_type="A10G", + ) + + # Deploy the application + llm_app = build_pd_openai_app( + dict( + prefill_config=config, + decode_config=config, + ) + ) + + serve.run(llm_app) + + + # Querying the model via openai client + from openai import OpenAI + + # Initialize client + client = OpenAI(base_url="http://localhost:8000/v1", api_key="fake-key") + + # Basic completion + response = client.chat.completions.create( + model="qwen-0.5b", + messages=[{"role": "user", "content": "Hello!"}] + ) + + .. code-block:: yaml + :caption: Example usage in YAML. + + # config.yaml + applications: + - args: + prefill_config: + model_loading_config: + model_id: qwen-0.5b + model_source: Qwen/Qwen2.5-0.5B-Instruct + accelerator_type: A10G + deployment_config: + autoscaling_config: + min_replicas: 1 + max_replicas: 2 + decode_config: + model_loading_config: + model_id: qwen-1.5b + model_source: Qwen/Qwen2.5-1.5B-Instruct + accelerator_type: A10G + deployment_config: + autoscaling_config: + min_replicas: 1 + max_replicas: 2 + import_path: ray.serve.llm:build_pd_openai_app + name: llm_app + route_prefix: "/" + + + Args: + pd_serving_args: The dictionary containing prefill and decode configs. See PDServingArgs for more details. + + Returns: + The configured Ray Serve Application router. + """ + from ray.llm._internal.serve.serving_patterns.prefill_decode.builder import ( + build_pd_openai_app, + ) + + return build_pd_openai_app(pd_serving_args=pd_serving_args) + + +@PublicAPI(stability="alpha") +def build_dp_deployment( + llm_config: "LLMConfig", + *, + name_prefix: Optional[str] = None, + override_serve_options: Optional[dict] = None, +) -> "Application": + """Build a data parallel attention LLM deployment. + + Args: + llm_config: The LLM configuration. + name_prefix: The prefix to add to the deployment name. + override_serve_options: The optional serve options to override the + default options. + + Returns: + The Ray Serve Application for the data parallel attention LLM deployment. + """ + from ray.llm._internal.serve.serving_patterns.data_parallel.builder import ( + build_dp_deployment, + ) + + return build_dp_deployment( + llm_config=llm_config, + name_prefix=name_prefix, + override_serve_options=override_serve_options, + ) + + +@PublicAPI(stability="alpha") +def build_dp_openai_app(dp_serving_args: dict) -> "Application": + """Build an OpenAI compatible app with the DP attention deployment + setup from the given builder configuration. + + Args: + dp_serving_args: The configuration for the builder. It has to conform + to the DPOpenAiServingArgs pydantic model. + + Returns: + The configured Ray Serve Application. + """ + from ray.llm._internal.serve.serving_patterns.data_parallel.builder import ( + build_dp_openai_app, + ) + + return build_dp_openai_app(builder_config=dp_serving_args) + + +__all__ = [ + "LLMConfig", + "LLMServingArgs", + "ModelLoadingConfig", + "CloudMirrorConfig", + "LoraConfig", + "build_llm_deployment", + "build_openai_app", + "build_pd_openai_app", + "build_dp_deployment", + "build_dp_openai_app", + "LLMServer", + "LLMRouter", +] diff --git a/lib/python3.12/site-packages/ray/serve/llm/__pycache__/__init__.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/llm/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..74241406329b6e52071b1e66b16c50d4fef9adae Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/llm/__pycache__/__init__.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/llm/__pycache__/deployment.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/llm/__pycache__/deployment.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..cf8ce6d28283cedf2a91705b1fd29818c05712a8 Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/llm/__pycache__/deployment.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/llm/__pycache__/gen_config.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/llm/__pycache__/gen_config.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c8b12f8ad1749c4d9d521fc9396cc59b41dacc38 Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/llm/__pycache__/gen_config.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/llm/__pycache__/ingress.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/llm/__pycache__/ingress.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..60012168e04088ee3a150b0089b8a71911efe475 Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/llm/__pycache__/ingress.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/llm/__pycache__/openai_api_models.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/llm/__pycache__/openai_api_models.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..23729fbd1e7fc5925eeeaff51778cf910d09365c Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/llm/__pycache__/openai_api_models.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/llm/__pycache__/request_router.cpython-312.pyc b/lib/python3.12/site-packages/ray/serve/llm/__pycache__/request_router.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2f8f27c77aa425c3bbe17805f6d528ee9a65bc82 Binary files /dev/null and b/lib/python3.12/site-packages/ray/serve/llm/__pycache__/request_router.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/serve/llm/deployment.py b/lib/python3.12/site-packages/ray/serve/llm/deployment.py new file mode 100644 index 0000000000000000000000000000000000000000..2ef669a18a67edeb3d420c8cbd2764752cd61e35 --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/llm/deployment.py @@ -0,0 +1,133 @@ +from ray.llm._internal.serve.core.server.llm_server import ( + LLMServer as InternalLLMServer, +) +from ray.llm._internal.serve.serving_patterns.data_parallel.dp_server import ( + DPServer as _DPServer, +) +from ray.llm._internal.serve.serving_patterns.prefill_decode.pd_server import ( + PDProxyServer as _PDProxyServer, +) +from ray.util.annotations import PublicAPI + +############# +# Deployments +############# + + +@PublicAPI(stability="alpha") +class LLMServer(InternalLLMServer): + """The implementation of the vLLM engine deployment. + + To build a Deployment object you should use `build_llm_deployment` function. + We also expose a lower level API for more control over the deployment class + through `serve.deployment` function. + + Examples: + .. testcode:: + :skipif: True + + from ray import serve + from ray.serve.llm import LLMConfig + from ray.serve.llm.deployment import LLMServer + + # Configure the model + llm_config = LLMConfig( + model_loading_config=dict( + served_model_name="llama-3.1-8b", + model_source="meta-llama/Llama-3.1-8b-instruct", + ), + deployment_config=dict( + autoscaling_config=dict( + min_replicas=1, + max_replicas=8, + ) + ), + ) + + # Build the deployment directly + serve_options = LLMServer.get_deployment_options(llm_config) + llm_app = serve.deployment(LLMServer).options( + **serve_options).bind(llm_config) + + model_handle = serve.run(llm_app) + + # Query the model via `chat` api + from ray.serve.llm.openai_api_models import ChatCompletionRequest + request = ChatCompletionRequest( + model="llama-3.1-8b", + messages=[ + { + "role": "user", + "content": "Hello, world!" + } + ] + ) + response = ray.get(model_handle.chat(request)) + print(response) + """ + + pass + + +@PublicAPI(stability="alpha") +class PDProxyServer(_PDProxyServer): + """A proxy server for prefill-decode disaggregation. + + This server acts as a proxy in a prefill-decode disaggregated system. + For chat and completions, proxy sends the request to the prefill server + with max_tokens=1 and then sends the returned metadata to the decode server. + + Args: + prefill_server: The prefill server deployment handle. + decode_server: The decode server deployment handle. + """ + + pass + + +@PublicAPI(stability="alpha") +class DPServer(_DPServer): + """Data Parallel LLM Server. + + This class is used to serve data parallel attention (DP Attention) + deployment paradigm, where the attention layers are replicated and + the MoE layers are sharded. DP Attention is typically used for models + like DeepSeek-V3. + + To build a Deployment object you should use `build_dp_deployment` function. + We also expose a lower level API for more control over the deployment class + through `serve.deployment` function. + + Examples: + .. testcode:: + :skipif: True + + from ray import serve + from ray.serve.llm import LLMConfig, build_dp_deployment + + # Configure the model + llm_config = LLMConfig( + model_loading_config=dict( + model_id="Qwen/Qwen2.5-0.5B-Instruct", + ), + engine_kwargs=dict( + data_parallel_size=2, + tensor_parallel_size=1, + ), + experimental_configs=dict( + dp_size_per_node=2, + ), + accelerator_type="A10G", + ) + + # Build the deployment + dp_app = build_dp_deployment(llm_config) + + # Deploy the application + model_handle = serve.run(dp_app) + """ + + pass + + +__all__ = ["LLMServer", "PDProxyServer", "DPServer"] diff --git a/lib/python3.12/site-packages/ray/serve/llm/gen_config.py b/lib/python3.12/site-packages/ray/serve/llm/gen_config.py new file mode 100644 index 0000000000000000000000000000000000000000..27d880238f262ec2b56e5aa09eaffbe92311188f --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/llm/gen_config.py @@ -0,0 +1,10 @@ +from ray.llm._internal.serve.config_generator.start import gen_config + + +def main(): + """Entry point for the CLI when called as a module.""" + gen_config() + + +if __name__ == "__main__": + main() diff --git a/lib/python3.12/site-packages/ray/serve/llm/ingress.py b/lib/python3.12/site-packages/ray/serve/llm/ingress.py new file mode 100644 index 0000000000000000000000000000000000000000..b35b8246c4242abbf61e2d6d21dee499b2f16f10 --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/llm/ingress.py @@ -0,0 +1,83 @@ +from ray.llm._internal.serve.core.ingress.ingress import ( + OpenAiIngress as _OpenAiIngress, + make_fastapi_ingress, +) +from ray.util.annotations import PublicAPI + + +@PublicAPI(stability="alpha") +class OpenAiIngress(_OpenAiIngress): + + """The implementation of the OpenAI compatible model router. + + This deployment creates the following endpoints: + - /v1/chat/completions: Chat interface (OpenAI-style) + - /v1/completions: Text completion + - /v1/models: List available models + - /v1/models/{model}: Model information + - /v1/embeddings: Text embeddings + - /v1/audio/transcriptions: Audio transcription + - /v1/score: Text scoring + + + Examples: + .. testcode:: + :skipif: True + + + from ray import serve + from ray.serve.llm import LLMConfig + from ray.serve.llm.deployment import LLMServer + from ray.serve.llm.ingress import OpenAiIngress, make_fastapi_ingress + + llm_config1 = LLMConfig( + model_loading_config=dict( + model_id="qwen-0.5b", + model_source="Qwen/Qwen2.5-0.5B-Instruct", + ), + deployment_config=dict( + autoscaling_config=dict( + min_replicas=1, max_replicas=2, + ) + ), + accelerator_type="A10G", + ) + + llm_config2 = LLMConfig( + model_loading_config=dict( + model_id="qwen-1.5b", + model_source="Qwen/Qwen2.5-1.5B-Instruct", + ), + deployment_config=dict( + autoscaling_config=dict( + min_replicas=1, max_replicas=2, + ) + ), + accelerator_type="A10G", + ) + + # deployment #1 + server_options1 = LLMServer.get_deployment_options(llm_config1) + server_deployment1 = serve.deployment(LLMServer).options( + **server_options1).bind(llm_config1) + + # deployment #2 + server_options2 = LLMServer.get_deployment_options(llm_config2) + server_deployment2 = serve.deployment(LLMServer).options( + **server_options2).bind(llm_config2) + + # ingress + ingress_options = OpenAiIngress.get_deployment_options( + llm_configs=[llm_config1, llm_config2]) + ingress_cls = make_fastapi_ingress(OpenAiIngress) + ingress_deployment = serve.deployment(ingress_cls).options( + **ingress_options).bind([server_deployment1, server_deployment2]) + + # run + serve.run(ingress_deployment, blocking=True) + """ + + pass + + +__all__ = ["OpenAiIngress", "make_fastapi_ingress"] diff --git a/lib/python3.12/site-packages/ray/serve/llm/openai_api_models.py b/lib/python3.12/site-packages/ray/serve/llm/openai_api_models.py new file mode 100644 index 0000000000000000000000000000000000000000..18603ac3deb0754142ad4e8e822bf88e5127c309 --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/llm/openai_api_models.py @@ -0,0 +1,125 @@ +from ray.llm._internal.serve.core.configs.openai_api_models import ( + ChatCompletionRequest as _ChatCompletionRequest, + ChatCompletionResponse as _ChatCompletionResponse, + ChatCompletionStreamResponse as _ChatCompletionStreamResponse, + CompletionRequest as _CompletionRequest, + CompletionResponse as _CompletionResponse, + CompletionStreamResponse as _CompletionStreamResponse, + EmbeddingRequest as _EmbeddingRequest, + EmbeddingResponse as _EmbeddingResponse, + ErrorResponse as _ErrorResponse, + TranscriptionRequest as _TranscriptionRequest, + TranscriptionResponse as _TranscriptionResponse, + TranscriptionStreamResponse as _TranscriptionStreamResponse, +) +from ray.util.annotations import PublicAPI + + +@PublicAPI(stability="alpha") +class ChatCompletionRequest(_ChatCompletionRequest): + """ChatCompletionRequest is the request body for the chat completion API. + + This model is compatible with vLLM's OpenAI API models. + """ + + pass + + +@PublicAPI(stability="alpha") +class CompletionRequest(_CompletionRequest): + """CompletionRequest is the request body for the completion API. + + This model is compatible with vLLM's OpenAI API models. + """ + + pass + + +@PublicAPI(stability="alpha") +class ChatCompletionStreamResponse(_ChatCompletionStreamResponse): + """ChatCompletionStreamResponse is the response body for the chat completion API. + + This model is compatible with vLLM's OpenAI API models. + """ + + pass + + +@PublicAPI(stability="alpha") +class ChatCompletionResponse(_ChatCompletionResponse): + """ChatCompletionResponse is the response body for the chat completion API. + + This model is compatible with vLLM's OpenAI API models. + """ + + pass + + +@PublicAPI(stability="alpha") +class CompletionStreamResponse(_CompletionStreamResponse): + """CompletionStreamResponse is the response body for the completion API. + + This model is compatible with vLLM's OpenAI API models. + """ + + pass + + +@PublicAPI(stability="alpha") +class CompletionResponse(_CompletionResponse): + """CompletionResponse is the response body for the completion API. + + This model is compatible with vLLM's OpenAI API models. + """ + + pass + + +EmbeddingRequest = _EmbeddingRequest + + +@PublicAPI(stability="alpha") +class EmbeddingResponse(_EmbeddingResponse): + """EmbeddingResponse is the response body for the embedding API. + + This model is compatible with vLLM's OpenAI API models. + """ + + pass + + +@PublicAPI(stability="alpha") +class TranscriptionRequest(_TranscriptionRequest): + """TranscriptionRequest is the request body for the transcription API. + + This model is compatible with vLLM's OpenAI API models. + """ + + pass + + +@PublicAPI(stability="alpha") +class TranscriptionResponse(_TranscriptionResponse): + """TranscriptionResponse is the response body for the transcription API. + + This model is compatible with vLLM's OpenAI API models. + """ + + pass + + +@PublicAPI(stability="alpha") +class TranscriptionStreamResponse(_TranscriptionStreamResponse): + """TranscriptionStreamResponse is the response body for the transcription API. + + This model is compatible with vLLM's OpenAI API models. + """ + + pass + + +@PublicAPI(stability="alpha") +class ErrorResponse(_ErrorResponse): + """The returned response in case of an error.""" + + pass diff --git a/lib/python3.12/site-packages/ray/serve/llm/request_router.py b/lib/python3.12/site-packages/ray/serve/llm/request_router.py new file mode 100644 index 0000000000000000000000000000000000000000..a78cd7cd0bc57f7e953955252e37ac6a6d715552 --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/llm/request_router.py @@ -0,0 +1,42 @@ +from ray.llm._internal.serve.routing_policies.prefix_aware.prefix_aware_router import ( + PrefixCacheAffinityRouter as _PrefixCacheAffinityRouter, +) +from ray.util.annotations import PublicAPI + + +@PublicAPI(stability="alpha") +class PrefixCacheAffinityRouter(_PrefixCacheAffinityRouter): + """A request router that is aware of the KV cache. + + This router optimizes request routing by considering KV cache locality, + directing requests with similar prefixes to the same replica to improve + cache hit rates. + + The internal policy is this (it may change in the future): + + 1. Mixes between three strategies to balance prefix cache hit rate and load + balancing: + - When load is balanced (queue length difference < threshold), it + selects replicas with the highest prefix match rate for the input text + - When load is balanced but match rate is below 10%, it falls back to + the smallest tenants (i.e. the replica with the least kv cache) + - When load is imbalanced, it uses the default Power of Two selection + + 2. Maintains a prefix tree to track which replicas have processed similar + inputs: + - Inserts prompt text into the prefix tree after routing + - Uses this history to inform future routing decisions + + Parameters: + imbalanced_threshold: The threshold for considering the load imbalanced. + match_rate_threshold: The threshold for considering the match rate. + do_eviction: Whether to do eviction. + eviction_threshold_chars: Number of characters in the tree to trigger + eviction. + eviction_target_chars: Number of characters in the tree to target for + eviction. + eviction_interval_secs: How often (in seconds) to run the eviction + policy. + """ + + pass diff --git a/lib/python3.12/site-packages/ray/serve/metrics.py b/lib/python3.12/site-packages/ray/serve/metrics.py new file mode 100644 index 0000000000000000000000000000000000000000..bce6e49440047a77b30199e4938484cdc938f3df --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/metrics.py @@ -0,0 +1,249 @@ +from typing import Dict, List, Optional, Tuple, Union + +import ray +from ray.serve import context +from ray.util import metrics +from ray.util.annotations import PublicAPI + +DEPLOYMENT_TAG = "deployment" +REPLICA_TAG = "replica" +APPLICATION_TAG = "application" +ROUTE_TAG = "route" + + +def _add_serve_metric_tags(tag_keys: Optional[Tuple[str]] = None) -> Tuple[str]: + """Add serve context tags to the tag_keys""" + if tag_keys is None: + tag_keys = tuple() + + # If the context doesn't exist, no serve tag is added. + if context._get_internal_replica_context() is None: + return tag_keys + # Check no collision with customer tag + if DEPLOYMENT_TAG in tag_keys: + raise ValueError(f"'{DEPLOYMENT_TAG}' tag is reserved for Ray Serve metrics") + if REPLICA_TAG in tag_keys: + raise ValueError(f"'{REPLICA_TAG}' tag is reserved for Ray Serve metrics") + if APPLICATION_TAG in tag_keys: + raise ValueError(f"'{APPLICATION_TAG}' tag is reserved for Ray Serve metrics") + + # Get serve tag inserted: + ray_serve_tags = (DEPLOYMENT_TAG, REPLICA_TAG) + if context._get_internal_replica_context().app_name: + ray_serve_tags += (APPLICATION_TAG,) + if tag_keys: + tag_keys = ray_serve_tags + tag_keys + else: + tag_keys = ray_serve_tags + return tag_keys + + +def _add_serve_metric_default_tags(default_tags: Dict[str, str]): + """Add serve context tags and values to the default_tags""" + if context._get_internal_replica_context() is None: + return default_tags + if DEPLOYMENT_TAG in default_tags: + raise ValueError(f"'{DEPLOYMENT_TAG}' tag is reserved for Ray Serve metrics") + if REPLICA_TAG in default_tags: + raise ValueError(f"'{REPLICA_TAG}' tag is reserved for Ray Serve metrics") + if APPLICATION_TAG in default_tags: + raise ValueError(f"'{APPLICATION_TAG}' tag is reserved for Ray Serve metrics") + replica_context = context._get_internal_replica_context() + # TODO(zcin): use replica_context.deployment for deployment tag + default_tags[DEPLOYMENT_TAG] = replica_context.deployment + default_tags[REPLICA_TAG] = replica_context.replica_tag + if replica_context.app_name: + default_tags[APPLICATION_TAG] = replica_context.app_name + return default_tags + + +def _add_serve_context_tag_values(tag_keys: Tuple, tags: Dict[str, str]): + """Add serve context tag values to the metric tags""" + + _request_context = ray.serve.context._get_serve_request_context() + if ROUTE_TAG in tag_keys and ROUTE_TAG not in tags: + tags[ROUTE_TAG] = _request_context.route + + +@PublicAPI(stability="beta") +class Counter(metrics.Counter): + """A serve cumulative metric that is monotonically increasing. + + This corresponds to Prometheus' counter metric: + https://prometheus.io/docs/concepts/metric_types/#counter + + Serve-related tags ("deployment", "replica", "application", "route") + are added automatically if not provided. + + .. code-block:: python + + @serve.deployment + class MyDeployment: + def __init__(self): + self.num_requests = 0 + self.my_counter = metrics.Counter( + "my_counter", + description=("The number of odd-numbered requests " + "to this deployment."), + tag_keys=("model",), + ) + self.my_counter.set_default_tags({"model": "123"}) + + def __call__(self): + self.num_requests += 1 + if self.num_requests % 2 == 1: + self.my_counter.inc() + + .. note:: + + Before Ray 2.10, this exports a Prometheus gauge metric instead of + a counter metric. + Starting in Ray 2.10, this exports both the proper counter metric + (with a suffix "_total") and gauge metric (for compatibility). + The gauge metric will be removed in a future Ray release and you can set + `RAY_EXPORT_COUNTER_AS_GAUGE=0` to disable exporting it in the meantime. + + Args: + name: Name of the metric. + description: Description of the metric. + tag_keys: Tag keys of the metric. + """ + + def __init__( + self, name: str, description: str = "", tag_keys: Optional[Tuple[str]] = None + ): + if tag_keys and not isinstance(tag_keys, tuple): + raise TypeError( + "tag_keys should be a tuple type, got: " f"{type(tag_keys)}" + ) + tag_keys = _add_serve_metric_tags(tag_keys) + super().__init__(name, description, tag_keys) + self.set_default_tags({}) + + def set_default_tags(self, default_tags: Dict[str, str]): + super().set_default_tags(_add_serve_metric_default_tags(default_tags)) + + def inc(self, value: Union[int, float] = 1.0, tags: Dict[str, str] = None): + """Increment the counter by the given value, add serve context + tag values to the tags + """ + _add_serve_context_tag_values(self._tag_keys, tags) + super().inc(value, tags) + + +@PublicAPI(stability="beta") +class Gauge(metrics.Gauge): + """Gauges keep the last recorded value and drop everything before. + + This corresponds to Prometheus' gauge metric: + https://prometheus.io/docs/concepts/metric_types/#gauge + + Serve-related tags ("deployment", "replica", "application", "route") + are added automatically if not provided. + + .. code-block:: python + + @serve.deployment + class MyDeployment: + def __init__(self): + self.num_requests = 0 + self.my_gauge = metrics.Gauge( + "my_gauge", + description=("The current memory usage."), + tag_keys=("model",), + ) + self.my_counter.set_default_tags({"model": "123"}) + + def __call__(self): + process = psutil.Process() + self.gauge.set(process.memory_info().rss) + + Args: + name: Name of the metric. + description: Description of the metric. + tag_keys: Tag keys of the metric. + """ + + def __init__( + self, name: str, description: str = "", tag_keys: Optional[Tuple[str]] = None + ): + if tag_keys and not isinstance(tag_keys, tuple): + raise TypeError( + "tag_keys should be a tuple type, got: " f"{type(tag_keys)}" + ) + tag_keys = _add_serve_metric_tags(tag_keys) + super().__init__(name, description, tag_keys) + self.set_default_tags({}) + + def set_default_tags(self, default_tags: Dict[str, str]): + super().set_default_tags(_add_serve_metric_default_tags(default_tags)) + + def set(self, value: Union[int, float], tags: Dict[str, str] = None): + """Set the gauge to the given value, add serve context + tag values to the tags + """ + _add_serve_context_tag_values(self._tag_keys, tags) + super().set(value, tags) + + +@PublicAPI(stability="beta") +class Histogram(metrics.Histogram): + """Tracks the size and number of events in buckets. + + Histograms allow you to calculate aggregate quantiles + such as 25, 50, 95, 99 percentile latency for an RPC. + + This corresponds to Prometheus' histogram metric: + https://prometheus.io/docs/concepts/metric_types/#histogram + + Serve-related tags ("deployment", "replica", "application", "route") + are added automatically if not provided. + + .. code-block:: python + + @serve.deployment + class MyDeployment: + def __init__(self): + self.my_histogram = Histogram( + "my_histogram", + description=("Histogram of the __call__ method running time."), + boundaries=[1,2,4,8,16,32,64], + tag_keys=("model",), + ) + self.my_histogram.set_default_tags({"model": "123"}) + + def __call__(self): + start = time.time() + self.my_histogram.observe(time.time() - start) + + Args: + name: Name of the metric. + description: Description of the metric. + boundaries: Boundaries of histogram buckets. + tag_keys: Tag keys of the metric. + """ + + def __init__( + self, + name: str, + description: str = "", + boundaries: List[float] = None, + tag_keys: Optional[Tuple[str]] = None, + ): + if tag_keys and not isinstance(tag_keys, tuple): + raise TypeError( + "tag_keys should be a tuple type, got: " f"{type(tag_keys)}" + ) + tag_keys = _add_serve_metric_tags(tag_keys) + super().__init__(name, description, boundaries, tag_keys) + self.set_default_tags({}) + + def set_default_tags(self, default_tags: Dict[str, str]): + super().set_default_tags(_add_serve_metric_default_tags(default_tags)) + + def observe(self, value: Union[int, float], tags: Dict[str, str] = None): + """Observe the given value, add serve context + tag values to the tags + """ + _add_serve_context_tag_values(self._tag_keys, tags) + super().observe(value, tags) diff --git a/lib/python3.12/site-packages/ray/serve/multiplex.py b/lib/python3.12/site-packages/ray/serve/multiplex.py new file mode 100644 index 0000000000000000000000000000000000000000..55d526a9a00e83084503d4c4526d10b98a95e131 --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/multiplex.py @@ -0,0 +1,260 @@ +import asyncio +import inspect +import logging +import time +from collections import OrderedDict +from typing import Any, Callable, List, Set + +from ray.serve import metrics +from ray.serve._private.common import ReplicaID, RequestRoutingInfo +from ray.serve._private.constants import ( + MODEL_LOAD_LATENCY_BUCKETS_MS, + PUSH_MULTIPLEXED_MODEL_IDS_INTERVAL_S, + SERVE_LOGGER_NAME, +) +from ray.serve._private.metrics_utils import MetricsPusher +from ray.serve._private.usage import ServeUsageTag +from ray.serve.context import _get_global_client, _get_internal_replica_context + +logger = logging.getLogger(SERVE_LOGGER_NAME) + + +class _ModelMultiplexWrapper: + """A wrapper class that wraps the model load function and + provides the LRU caching functionality. + + The model multiplexer is a wrapper class that wraps the model load function + and provides the LRU caching functionality, and the model load function should + be a coroutine function that takes the model ID as the first argument and + returns the user-constructed model object. + The model multiplexer will also ensure that the number of models on the current + replica does not exceed the specified limit. + The model will be unloaded in the LRU order, the model multiplexer will call the + model's __del__ attribute if it exists to clean up the model resources eagerly. + + """ + + _PUSH_MULTIPLEXED_MODEL_IDS_TASK_NAME = "push_multiplexed_model_ids" + + def __init__( + self, + model_load_func: Callable[[str], Any], + self_arg: Any, + max_num_models_per_replica: int, + ): + """Initialize the model multiplexer. + Args: + model_load_func: the model load async function. + self_arg: self argument when model_load_func is class method. + max_num_models_per_replica: the maximum number of models to be loaded on the + current replica. If it is -1, there is no limit for the number of models + per replica. + """ + + ServeUsageTag.MULTIPLEXED_API_USED.record("1") + + self.models = OrderedDict() + self._func: Callable = model_load_func + self.self_arg: Any = self_arg + self.max_num_models_per_replica: int = max_num_models_per_replica + + # log MODEL_LOAD_LATENCY_BUCKET_MS + logger.debug(f"MODEL_LOAD_LATENCY_BUCKET_MS: {MODEL_LOAD_LATENCY_BUCKETS_MS}") + self.model_load_latency_ms = metrics.Histogram( + "serve_multiplexed_model_load_latency_ms", + description="The time it takes to load a model.", + boundaries=MODEL_LOAD_LATENCY_BUCKETS_MS, + ) + self.model_unload_latency_ms = metrics.Histogram( + "serve_multiplexed_model_unload_latency_ms", + description="The time it takes to unload a model.", + boundaries=MODEL_LOAD_LATENCY_BUCKETS_MS, + ) + self.num_models_gauge = metrics.Gauge( + "serve_num_multiplexed_models", + description="The number of models loaded on the current replica.", + ) + + self.registered_model_gauge = metrics.Gauge( + "serve_registered_multiplexed_model_id", + description="The model id registered on the current replica.", + tag_keys=("model_id",), + ) + self.get_model_requests_counter = metrics.Counter( + "serve_multiplexed_get_model_requests_counter", + description="The counter for get model requests on the current replica.", + ) + self.models_unload_counter = metrics.Counter( + "serve_multiplexed_models_unload_counter", + description="The counter for unloaded models on the current replica.", + ) + self.models_load_counter = metrics.Counter( + "serve_multiplexed_models_load_counter", + description="The counter for loaded models on the current replica.", + ) + + context = _get_internal_replica_context() + if context is None: + raise RuntimeError( + "`@serve.multiplex` can only be used within a deployment " + "(failed to retrieve Serve replica context)." + ) + + self._app_name: str = context.app_name + self._deployment_name: str = context.deployment + self._replica_id: ReplicaID = context.replica_id + + # Whether to push the multiplexed replica info to the controller. + self._push_multiplexed_replica_info: bool = False + + # Model cache lock to ensure that only one model is loading/unloading at a time. + self._model_cache_lock = asyncio.Lock() + # The set of model IDs that are being loaded. This is used to early push + # model ids info to the controller. The tasks will be added when there is cache + # miss, and will be removed when the model is loaded successfully or + # failed to load. + self._model_load_tasks: Set[str] = set() + + self.metrics_pusher = MetricsPusher() + self.metrics_pusher.register_or_update_task( + self._PUSH_MULTIPLEXED_MODEL_IDS_TASK_NAME, + self._push_model_ids_info, + PUSH_MULTIPLEXED_MODEL_IDS_INTERVAL_S, + ) + self.metrics_pusher.start() + + def _get_loading_and_loaded_model_ids(self) -> List[str]: + """Get the model IDs of the loaded models & loading models in the replica. + This is to push the model id information early to the controller, so that + requests can be routed to the replica. + """ + models_list = set(self.models.keys()) + models_list.update(self._model_load_tasks) + return list(models_list) + + def _push_model_ids_info(self): + """Push the multiplexed replica info to the controller.""" + try: + self.num_models_gauge.set(len(self.models)) + + for model_id in self.models: + self.registered_model_gauge.set(1, tags={"model_id": model_id}) + + if self._push_multiplexed_replica_info: + _get_global_client().record_request_routing_info( + RequestRoutingInfo( + replica_id=self._replica_id, + multiplexed_model_ids=self._get_loading_and_loaded_model_ids(), + ) + ) + self._push_multiplexed_replica_info = False + except Exception as e: + logger.warning( + "Failed to push the multiplexed replica info " + f"to the controller. Error: {e}" + ) + + async def shutdown(self): + """Unload all the models when the model multiplexer is deleted.""" + while len(self.models) > 0: + try: + await self.unload_model_lru() + except Exception as e: + logger.exception( + f"Failed to unload model. Error: {e}", + ) + + async def load_model(self, model_id: str) -> Any: + """Load the model if it is not loaded yet, and return + the user-constructed model object. + + Args: + model_id: the model ID. + + Returns: + The user-constructed model object. + """ + + if type(model_id) is not str: + raise TypeError("The model ID must be a string.") + + if not model_id: + raise ValueError("The model ID cannot be empty.") + + self.get_model_requests_counter.inc() + + if model_id in self.models: + # Move the model to the end of the OrderedDict to ensure LRU caching. + model = self.models.pop(model_id) + self.models[model_id] = model + return self.models[model_id] + else: + # Set the flag to push the multiplexed replica info to the controller + # before loading the model. This is to make sure we can push the model + # id info to the controller/router early, so that requests can be routed to + # the replica. + self._push_multiplexed_replica_info = True + self._model_load_tasks.add(model_id) + async with self._model_cache_lock: + # Check if the model has been loaded by another request. + if model_id in self.models: + return self.models[model_id] + try: + # If the number of models per replica is specified, check + # if the number of models on the current replica has + # reached the limit. + if ( + self.max_num_models_per_replica > 0 + and len(self.models) >= self.max_num_models_per_replica + ): + # Unload the least recently used model. + await self.unload_model_lru() + self._push_multiplexed_replica_info = True + + # Load the model. + logger.info(f"Loading model '{model_id}'.") + self.models_load_counter.inc() + load_start_time = time.time() + if self.self_arg is None: + self.models[model_id] = await self._func(model_id) + else: + self.models[model_id] = await self._func( + self.self_arg, model_id + ) + load_latency_ms = (time.time() - load_start_time) * 1000.0 + logger.info( + f"Successfully loaded model '{model_id}' in " + f"{load_latency_ms:.1f}ms." + ) + self._model_load_tasks.discard(model_id) + self.model_load_latency_ms.observe(load_latency_ms) + return self.models[model_id] + except Exception as e: + logger.error( + f"Failed to load model '{model_id}'. Error: {e}", + ) + self._model_load_tasks.discard(model_id) + raise e + + async def unload_model_lru(self) -> None: + """Unload the least recently used model.""" + + self.models_unload_counter.inc() + unload_start_time = time.time() + model_id, model = self.models.popitem(last=False) + logger.info(f"Unloading model '{model_id}'.") + + # If the model has __del__ attribute, call it. + # This is to clean up the model resources eagerly. + if hasattr(model, "__del__"): + if not inspect.iscoroutinefunction(model.__del__): + await asyncio.get_running_loop().run_in_executor(None, model.__del__) + else: + await model.__del__() + model.__del__ = lambda _: None + unload_latency_ms = (time.time() - unload_start_time) * 1000.0 + self.model_unload_latency_ms.observe(unload_latency_ms) + logger.info( + f"Successfully unloaded model '{model_id}' in {unload_latency_ms:.1f}ms." + ) + self.registered_model_gauge.set(0, tags={"model_id": model_id}) diff --git a/lib/python3.12/site-packages/ray/serve/schema.py b/lib/python3.12/site-packages/ray/serve/schema.py new file mode 100644 index 0000000000000000000000000000000000000000..6318207303cba722a543c36c843f555f71bf602e --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/schema.py @@ -0,0 +1,1608 @@ +import logging +from abc import ABC, abstractmethod +from collections import Counter +from dataclasses import dataclass, field +from enum import Enum +from typing import Any, Callable, Dict, List, Optional, Set, Union +from zlib import crc32 + +from ray._common.pydantic_compat import ( + BaseModel, + Extra, + Field, + NonNegativeInt, + PositiveInt, + StrictInt, + root_validator, + validator, +) +from ray._private.ray_logging.constants import LOGRECORD_STANDARD_ATTRS +from ray._private.runtime_env.packaging import parse_uri +from ray.serve._private.common import ( + DeploymentStatus, + DeploymentStatusTrigger, + ReplicaState, + RequestProtocol, + ServeDeployMode, +) +from ray.serve._private.constants import ( + DEFAULT_CONSUMER_CONCURRENCY, + DEFAULT_GRPC_PORT, + DEFAULT_MAX_ONGOING_REQUESTS, + DEFAULT_UVICORN_KEEP_ALIVE_TIMEOUT_S, + RAY_SERVE_LOG_ENCODING, + SERVE_DEFAULT_APP_NAME, +) +from ray.serve._private.deployment_info import DeploymentInfo +from ray.serve._private.utils import DEFAULT, validate_ssl_config +from ray.serve.config import ProxyLocation, RequestRouterConfig +from ray.util.annotations import PublicAPI + +# Shared amongst multiple schemas. +TARGET_CAPACITY_FIELD = Field( + default=None, + description=( + "[EXPERIMENTAL]: the target capacity percentage for all replicas across the " + "cluster. The `num_replicas`, `min_replicas`, `max_replicas`, and " + "`initial_replicas` for each deployment will be scaled by this percentage." + ), + ge=0, + le=100, +) + + +def _route_prefix_format(cls, v): + """ + The route_prefix + 1. must start with a / character + 2. must not end with a / character (unless the entire prefix is just /) + 3. cannot contain wildcards (must not have "{" or "}") + """ + + if v is None: + return v + + if not v.startswith("/"): + raise ValueError( + f'Got "{v}" for route_prefix. Route prefix must start with "/".' + ) + if len(v) > 1 and v.endswith("/"): + raise ValueError( + f'Got "{v}" for route_prefix. Route prefix ' + 'cannot end with "/" unless the ' + 'entire prefix is just "/".' + ) + if "{" in v or "}" in v: + raise ValueError( + f'Got "{v}" for route_prefix. Route prefix ' + "cannot contain wildcards, so it cannot " + 'contain "{" or "}".' + ) + + return v + + +@PublicAPI(stability="alpha") +class EncodingType(str, Enum): + """Encoding type for the serve logs.""" + + TEXT = "TEXT" + JSON = "JSON" + + +@PublicAPI(stability="alpha") +class LoggingConfig(BaseModel): + """Logging config schema for configuring serve components logs. + + Example: + + .. code-block:: python + + from ray import serve + from ray.serve.schema import LoggingConfig + # Set log level for the deployment. + @serve.deployment(LoggingConfig(log_level="DEBUG")) + class MyDeployment: + def __call__(self) -> str: + return "Hello world!" + # Set log directory for the deployment. + @serve.deployment(LoggingConfig(logs_dir="/my_dir")) + class MyDeployment: + def __call__(self) -> str: + return "Hello world!" + """ + + class Config: + extra = Extra.forbid + + encoding: Union[str, EncodingType] = Field( + default_factory=lambda: RAY_SERVE_LOG_ENCODING, + description=( + "Encoding type for the serve logs. Defaults to 'TEXT'. The default can be " + "overwritten using the `RAY_SERVE_LOG_ENCODING` environment variable. " + "'JSON' is also supported for structured logging." + ), + ) + log_level: Union[int, str] = Field( + default="INFO", + description=( + "Log level for the serve logs. Defaults to INFO. You can set it to " + "'DEBUG' to get more detailed debug logs." + ), + ) + logs_dir: Union[str, None] = Field( + default=None, + description=( + "Directory to store the logs. Default to None, which means " + "logs will be stored in the default directory " + "('/tmp/ray/session_latest/logs/serve/...')." + ), + ) + enable_access_log: bool = Field( + default=True, + description=( + "Whether to enable access logs for each request. Default to True." + ), + ) + additional_log_standard_attrs: List[str] = Field( + default_factory=list, + description=( + "Default attributes from the Python standard logger that will be " + "added to all log records. " + "See https://docs.python.org/3/library/logging.html#logrecord-attributes " + "for a list of available attributes." + ), + ) + + @validator("encoding") + def valid_encoding_format(cls, v): + if v not in list(EncodingType): + raise ValueError( + f"Got '{v}' for encoding. Encoding must be one " + f"of {set(EncodingType)}." + ) + + return v + + @validator("log_level") + def valid_log_level(cls, v): + if isinstance(v, int): + if v not in logging._levelToName: + raise ValueError( + f'Got "{v}" for log_level. log_level must be one of ' + f"{list(logging._levelToName.keys())}." + ) + return logging._levelToName[v] + + if v not in logging._nameToLevel: + raise ValueError( + f'Got "{v}" for log_level. log_level must be one of ' + f"{list(logging._nameToLevel.keys())}." + ) + return v + + @validator("additional_log_standard_attrs") + def valid_additional_log_standard_attrs(cls, v): + for attr in v: + if attr not in LOGRECORD_STANDARD_ATTRS: + raise ValueError( + f"Unknown attribute '{attr}'. " + f"Additional log standard attributes must be one of {LOGRECORD_STANDARD_ATTRS}." + ) + return list(set(v)) + + def _compute_hash(self) -> int: + return crc32( + ( + str(self.encoding) + + str(self.log_level) + + str(self.logs_dir) + + str(self.enable_access_log) + ).encode("utf-8") + ) + + def __eq__(self, other: Any) -> bool: + if not isinstance(other, LoggingConfig): + return False + return self._compute_hash() == other._compute_hash() + + +@PublicAPI(stability="stable") +class RayActorOptionsSchema(BaseModel): + """Options with which to start a replica actor.""" + + runtime_env: dict = Field( + default={}, + description=( + "This deployment's runtime_env. working_dir and " + "py_modules may contain only remote URIs." + ), + ) + num_cpus: float = Field( + default=None, + description=( + "The number of CPUs required by the deployment's " + "application per replica. This is the same as a ray " + "actor's num_cpus. Uses a default if null." + ), + ge=0, + ) + num_gpus: float = Field( + default=None, + description=( + "The number of GPUs required by the deployment's " + "application per replica. This is the same as a ray " + "actor's num_gpus. Uses a default if null." + ), + ge=0, + ) + memory: float = Field( + default=None, + description=( + "Restrict the heap memory usage of each replica. Uses a default if null." + ), + ge=0, + ) + resources: Dict = Field( + default={}, + description=("The custom resources required by each replica."), + ) + accelerator_type: str = Field( + default=None, + description=( + "Forces replicas to run on nodes with the specified accelerator type." + "See :ref:`accelerator types <accelerator_types>`." + ), + ) + + @validator("runtime_env") + def runtime_env_contains_remote_uris(cls, v): + # Ensure that all uris in py_modules and working_dir are remote + + if v is None: + return + + uris = v.get("py_modules", []) + if "working_dir" in v: + uris = [*uris, v["working_dir"]] + + for uri in uris: + if uri is not None: + try: + parse_uri(uri) + except ValueError as e: + raise ValueError( + "runtime_envs in the Serve config support only " + "remote URIs in working_dir and py_modules. Got " + f"error when parsing URI: {e}" + ) + + return v + + +@PublicAPI(stability="stable") +class DeploymentSchema(BaseModel, allow_population_by_field_name=True): + """ + Specifies options for one deployment within a Serve application. For each deployment + this can optionally be included in `ServeApplicationSchema` to override deployment + options specified in code. + """ + + name: str = Field( + ..., description=("Globally-unique name identifying this deployment.") + ) + num_replicas: Optional[Union[PositiveInt, str]] = Field( + default=DEFAULT.VALUE, + description=( + "The number of processes that handle requests to this " + "deployment. Uses a default if null. Can also be set to " + "`auto` for a default autoscaling configuration " + "(experimental)." + ), + ) + max_ongoing_requests: int = Field( + default=DEFAULT.VALUE, + description=( + "Maximum number of requests that are sent in parallel " + "to each replica of this deployment. The limit is enforced across all " + "callers (HTTP requests or DeploymentHandles). Defaults to " + f"{DEFAULT_MAX_ONGOING_REQUESTS}." + ), + gt=0, + ) + max_queued_requests: StrictInt = Field( + default=DEFAULT.VALUE, + description=( + "[DEPRECATED] The max number of requests that will be executed at once in " + f"each replica. Defaults to {DEFAULT_MAX_ONGOING_REQUESTS}." + ), + ) + user_config: Optional[Dict] = Field( + default=DEFAULT.VALUE, + description=( + "Config to pass into this deployment's " + "reconfigure method. This can be updated dynamically " + "without restarting replicas" + ), + ) + autoscaling_config: Optional[Dict] = Field( + default=DEFAULT.VALUE, + description=( + "Config specifying autoscaling " + "parameters for the deployment's number of replicas. " + "If null, the deployment won't autoscale its number of " + "replicas; the number of replicas will be fixed at " + "num_replicas." + ), + ) + graceful_shutdown_wait_loop_s: float = Field( + default=DEFAULT.VALUE, + description=( + "Duration that deployment replicas will wait until there " + "is no more work to be done before shutting down. Uses a " + "default if null." + ), + ge=0, + ) + graceful_shutdown_timeout_s: float = Field( + default=DEFAULT.VALUE, + description=( + "Serve controller waits for this duration before " + "forcefully killing the replica for shutdown. Uses a " + "default if null." + ), + ge=0, + ) + health_check_period_s: float = Field( + default=DEFAULT.VALUE, + description=( + "Frequency at which the controller will health check " + "replicas. Uses a default if null." + ), + gt=0, + ) + health_check_timeout_s: float = Field( + default=DEFAULT.VALUE, + description=( + "Timeout that the controller will wait for a response " + "from the replica's health check before marking it " + "unhealthy. Uses a default if null." + ), + gt=0, + ) + ray_actor_options: RayActorOptionsSchema = Field( + default=DEFAULT.VALUE, description="Options set for each replica actor." + ) + + placement_group_bundles: List[Dict[str, float]] = Field( + default=DEFAULT.VALUE, + description=( + "Define a set of placement group bundles to be " + "scheduled *for each replica* of this deployment. The replica actor will " + "be scheduled in the first bundle provided, so the resources specified in " + "`ray_actor_options` must be a subset of the first bundle's resources. All " + "actors and tasks created by the replica actor will be scheduled in the " + "placement group by default (`placement_group_capture_child_tasks` is set " + "to True)." + ), + ) + + placement_group_strategy: str = Field( + default=DEFAULT.VALUE, + description=( + "Strategy to use for the replica placement group " + "specified via `placement_group_bundles`. Defaults to `PACK`." + ), + ) + + max_replicas_per_node: int = Field( + default=DEFAULT.VALUE, + description=( + "The max number of replicas of this deployment that can run on a single " + "Valid values are None (default, no limit) or an integer in the range of " + "[1, 100]. " + ), + ) + logging_config: LoggingConfig = Field( + default=DEFAULT.VALUE, + description="Logging config for configuring serve deployment logs.", + ) + request_router_config: Union[Dict, RequestRouterConfig] = Field( + default=DEFAULT.VALUE, + description="Config for the request router used for this deployment.", + ) + + @root_validator + def validate_num_replicas_and_autoscaling_config(cls, values): + num_replicas = values.get("num_replicas", None) + autoscaling_config = values.get("autoscaling_config", None) + + # Cannot have `num_replicas` be an int and a non-null + # autoscaling config + if isinstance(num_replicas, int): + if autoscaling_config not in [None, DEFAULT.VALUE]: + raise ValueError( + "Manually setting num_replicas is not allowed " + "when autoscaling_config is provided." + ) + # A null `num_replicas` or `num_replicas="auto"` can be paired + # with a non-null autoscaling_config + elif num_replicas not in ["auto", None, DEFAULT.VALUE]: + raise ValueError( + f'`num_replicas` must be an int or "auto", but got: {num_replicas}' + ) + + return values + + @root_validator + def validate_max_replicas_per_node_and_placement_group_bundles(cls, values): + max_replicas_per_node = values.get("max_replicas_per_node", None) + placement_group_bundles = values.get("placement_group_bundles", None) + + if max_replicas_per_node not in [ + DEFAULT.VALUE, + None, + ] and placement_group_bundles not in [DEFAULT.VALUE, None]: + raise ValueError( + "Setting max_replicas_per_node is not allowed when " + "placement_group_bundles is provided." + ) + + return values + + @root_validator + def validate_max_queued_requests(cls, values): + max_queued_requests = values.get("max_queued_requests", None) + if max_queued_requests is None or max_queued_requests == DEFAULT.VALUE: + return values + + if max_queued_requests < 1 and max_queued_requests != -1: + raise ValueError( + "max_queued_requests must be -1 (no limit) or a positive integer." + ) + + return values + + def _get_user_configured_option_names(self) -> Set[str]: + """Get set of names for all user-configured options. + + Any field not set to DEFAULT.VALUE is considered a user-configured option. + """ + + return { + field for field, value in self.dict().items() if value is not DEFAULT.VALUE + } + + +def _deployment_info_to_schema(name: str, info: DeploymentInfo) -> DeploymentSchema: + """Converts a DeploymentInfo object to DeploymentSchema.""" + + schema = DeploymentSchema( + name=name, + max_ongoing_requests=info.deployment_config.max_ongoing_requests, + max_queued_requests=info.deployment_config.max_queued_requests, + user_config=info.deployment_config.user_config, + graceful_shutdown_wait_loop_s=( + info.deployment_config.graceful_shutdown_wait_loop_s + ), + graceful_shutdown_timeout_s=info.deployment_config.graceful_shutdown_timeout_s, + health_check_period_s=info.deployment_config.health_check_period_s, + health_check_timeout_s=info.deployment_config.health_check_timeout_s, + ray_actor_options=info.replica_config.ray_actor_options, + request_router_config=info.deployment_config.request_router_config, + ) + + if info.deployment_config.autoscaling_config is not None: + schema.autoscaling_config = info.deployment_config.autoscaling_config.dict() + else: + schema.num_replicas = info.deployment_config.num_replicas + + return schema + + +@PublicAPI(stability="stable") +class ServeApplicationSchema(BaseModel): + """ + Describes one Serve application, and currently can also be used as a standalone + config to deploy a single application to a Ray cluster. + """ + + name: str = Field( + default=SERVE_DEFAULT_APP_NAME, + description=( + "Application name, the name should be unique within the serve instance" + ), + ) + route_prefix: Optional[str] = Field( + default="/", + description=( + "Route prefix for HTTP requests. If not provided, it will use" + "route_prefix of the ingress deployment. By default, the ingress route " + "prefix is '/'." + ), + ) + import_path: str = Field( + ..., + description=( + "An import path to a bound deployment node. Should be of the " + 'form "module.submodule_1...submodule_n.' + 'dag_node". This is equivalent to ' + '"from module.submodule_1...submodule_n import ' + 'dag_node". Only works with Python ' + "applications. This field is REQUIRED when deploying Serve config " + "to a Ray cluster." + ), + ) + runtime_env: dict = Field( + default={}, + description=( + "The runtime_env that the deployment graph will be run in. " + "Per-deployment runtime_envs will inherit from this. working_dir " + "and py_modules may contain only remote URIs." + ), + ) + host: str = Field( + default="0.0.0.0", + description=( + "Host for HTTP servers to listen on. Defaults to " + '"0.0.0.0", which exposes Serve publicly. Cannot be updated once ' + "your Serve application has started running. The Serve application " + "must be shut down and restarted with the new host instead." + ), + ) + port: int = Field( + default=8000, + description=( + "Port for HTTP server. Defaults to 8000. Cannot be updated once " + "your Serve application has started running. The Serve application " + "must be shut down and restarted with the new port instead." + ), + ) + deployments: List[DeploymentSchema] = Field( + default=[], + description="Deployment options that override options specified in the code.", + ) + autoscaling_policy: Optional[dict] = Field( + default=None, + description=( + "Application-level autoscaling policy. " + "If null, serve fallbacks to autoscaling policy in each deployment. " + "This option is under development and not yet supported." + ), + ) + + args: Dict = Field( + default={}, + description="Arguments that will be passed to the application builder.", + ) + logging_config: LoggingConfig = Field( + default=None, + description="Logging config for configuring serve application logs.", + ) + + @property + def deployment_names(self) -> List[str]: + return [d.name for d in self.deployments] + + @validator("runtime_env") + def runtime_env_contains_remote_uris(cls, v): + # Ensure that all uris in py_modules and working_dir are remote. + if v is None: + return + + uris = v.get("py_modules", []) + if "working_dir" in v: + uris = [*uris, v["working_dir"]] + + for uri in uris: + if uri is not None: + try: + parse_uri(uri) + except ValueError as e: + raise ValueError( + "runtime_envs in the Serve config support only " + "remote URIs in working_dir and py_modules. Got " + f"error when parsing URI: {e}" + ) + + return v + + @validator("import_path") + def import_path_format_valid(cls, v: str): + if v is None: + return + + if ":" in v: + if v.count(":") > 1: + raise ValueError( + f'Got invalid import path "{v}". An ' + "import path may have at most one colon." + ) + if v.rfind(":") == 0 or v.rfind(":") == len(v) - 1: + raise ValueError( + f'Got invalid import path "{v}". An ' + "import path may not start or end with a colon." + ) + return v + else: + if v.count(".") < 1: + raise ValueError( + f'Got invalid import path "{v}". An ' + "import path must contain at least on dot or colon " + "separating the module (and potentially submodules) from " + 'the deployment graph. E.g.: "module.deployment_graph".' + ) + if v.rfind(".") == 0 or v.rfind(".") == len(v) - 1: + raise ValueError( + f'Got invalid import path "{v}". An ' + "import path may not start or end with a dot." + ) + + return v + + @staticmethod + def get_empty_schema_dict() -> Dict: + """Returns an empty app schema dictionary. + + Schema can be used as a representation of an empty Serve application config. + """ + + return { + "import_path": "", + "runtime_env": {}, + "deployments": [], + } + + +@PublicAPI(stability="alpha") +class gRPCOptionsSchema(BaseModel): + """Options to start the gRPC Proxy with.""" + + port: int = Field( + default=DEFAULT_GRPC_PORT, + description=( + "Port for gRPC server. Defaults to 9000. Cannot be updated once " + "Serve has started running. Serve must be shut down and restarted " + "with the new port instead." + ), + ) + grpc_servicer_functions: List[str] = Field( + default=[], + description=( + "List of import paths for gRPC `add_servicer_to_server` functions to add " + "to Serve's gRPC proxy. Default to empty list, which means no gRPC methods " + "will be added and no gRPC server will be started. The servicer functions " + "need to be importable from the context of where Serve is running." + ), + ) + request_timeout_s: float = Field( + default=None, + description="The timeout for gRPC requests. Defaults to no timeout.", + ) + + +@PublicAPI(stability="stable") +class HTTPOptionsSchema(BaseModel): + """Options to start the HTTP Proxy with. + + NOTE: This config allows extra parameters to make it forward-compatible (ie + older versions of Serve are able to accept configs from a newer versions, + simply ignoring new parameters). + """ + + host: str = Field( + default="0.0.0.0", + description=( + "Host for HTTP servers to listen on. Defaults to " + '"0.0.0.0", which exposes Serve publicly. Cannot be updated once ' + "Serve has started running. Serve must be shut down and restarted " + "with the new host instead." + ), + ) + port: int = Field( + default=8000, + description=( + "Port for HTTP server. Defaults to 8000. Cannot be updated once " + "Serve has started running. Serve must be shut down and restarted " + "with the new port instead." + ), + ) + root_path: str = Field( + default="", + description=( + 'Root path to mount the serve application (for example, "/serve"). All ' + 'deployment routes will be prefixed with this path. Defaults to "".' + ), + ) + request_timeout_s: float = Field( + default=None, + description="The timeout for HTTP requests. Defaults to no timeout.", + ) + keep_alive_timeout_s: int = Field( + default=DEFAULT_UVICORN_KEEP_ALIVE_TIMEOUT_S, + description="The HTTP proxy will keep idle connections alive for this duration " + "before closing them when no requests are ongoing. Defaults to " + f"{DEFAULT_UVICORN_KEEP_ALIVE_TIMEOUT_S} seconds.", + ) + ssl_keyfile: Optional[str] = Field( + default=None, + description="Path to the SSL key file for HTTPS. If provided with ssl_certfile, " + "the HTTP server will use HTTPS. Cannot be updated once Serve has started.", + ) + ssl_certfile: Optional[str] = Field( + default=None, + description="Path to the SSL certificate file for HTTPS. If provided with " + "ssl_keyfile, the HTTP server will use HTTPS. Cannot be updated once Serve " + "has started.", + ) + ssl_keyfile_password: Optional[str] = Field( + default=None, + description="Password for the SSL key file, if encrypted.", + ) + ssl_ca_certs: Optional[str] = Field( + default=None, + description="Path to the CA certificate file for verifying client certificates.", + ) + + @validator("ssl_certfile") + def validate_ssl_certfile(cls, v, values): + ssl_keyfile = values.get("ssl_keyfile") + validate_ssl_config(v, ssl_keyfile) + return v + + +@PublicAPI(stability="stable") +class ServeDeploySchema(BaseModel): + """ + Multi-application config for deploying a list of Serve applications to the Ray + cluster. + + This is the request JSON schema for the v2 REST API + `PUT "/api/serve/applications/"`. + + NOTE: This config allows extra parameters to make it forward-compatible (ie + older versions of Serve are able to accept configs from a newer versions, + simply ignoring new parameters) + """ + + proxy_location: ProxyLocation = Field( + default=ProxyLocation.EveryNode, + description=( + "Config for where to run proxies for ingress traffic to the cluster." + ), + ) + http_options: HTTPOptionsSchema = Field( + default=HTTPOptionsSchema(), description="Options to start the HTTP Proxy with." + ) + grpc_options: gRPCOptionsSchema = Field( + default=gRPCOptionsSchema(), description="Options to start the gRPC Proxy with." + ) + logging_config: LoggingConfig = Field( + default=None, + description="Logging config for configuring serve components logs.", + ) + applications: List[ServeApplicationSchema] = Field( + ..., description="The set of applications to run on the Ray cluster." + ) + target_capacity: Optional[float] = TARGET_CAPACITY_FIELD + + @validator("applications") + def application_names_unique(cls, v): + # Ensure there are no duplicate applications listed + names = [app.name for app in v] + duplicates = {f'"{name}"' for name in names if names.count(name) > 1} + if len(duplicates): + apps_str = ("application " if len(duplicates) == 1 else "applications ") + ( + ", ".join(duplicates) + ) + raise ValueError( + f"Found multiple configs for {apps_str}. Please remove all duplicates." + ) + return v + + @validator("applications") + def application_routes_unique(cls, v): + # Ensure each application with a non-null route prefix has unique route prefixes + routes = [app.route_prefix for app in v if app.route_prefix is not None] + duplicates = {f'"{route}"' for route in routes if routes.count(route) > 1} + if len(duplicates): + routes_str = ( + "route prefix " if len(duplicates) == 1 else "route prefixes " + ) + (", ".join(duplicates)) + raise ValueError( + f"Found duplicate applications for {routes_str}. Please ensure each " + "application's route_prefix is unique." + ) + return v + + @validator("applications") + def application_names_nonempty(cls, v): + for app in v: + if len(app.name) == 0: + raise ValueError("Application names must be nonempty.") + return v + + @root_validator + def nested_host_and_port(cls, values): + # TODO (zcin): ServeApplicationSchema still needs to have host and port + # fields to support single-app mode, but in multi-app mode the host and port + # fields at the top-level deploy config is used instead. Eventually, after + # migration, we should remove these fields from ServeApplicationSchema. + for app_config in values.get("applications"): + if "host" in app_config.dict(exclude_unset=True): + raise ValueError( + f'Host "{app_config.host}" is set in the config for application ' + f"`{app_config.name}`. Please remove it and set host in the top " + "level deploy config only." + ) + if "port" in app_config.dict(exclude_unset=True): + raise ValueError( + f"Port {app_config.port} is set in the config for application " + f"`{app_config.name}`. Please remove it and set port in the top " + "level deploy config only." + ) + return values + + @staticmethod + def get_empty_schema_dict() -> Dict: + """Returns an empty deploy schema dictionary. + + Schema can be used as a representation of an empty Serve deploy config. + """ + + return {"applications": []} + + +# Keep in sync with ServeSystemActorStatus in +# python/ray/dashboard/client/src/type/serve.ts +@PublicAPI(stability="stable") +class ProxyStatus(str, Enum): + """The current status of the proxy.""" + + STARTING = "STARTING" + HEALTHY = "HEALTHY" + UNHEALTHY = "UNHEALTHY" + DRAINING = "DRAINING" + # The DRAINED status is a momentary state + # just before the proxy is removed + # so this status won't show up on the dashboard. + DRAINED = "DRAINED" + + +@PublicAPI(stability="alpha") +@dataclass +class DeploymentStatusOverview: + """Describes the status of a deployment. + + Attributes: + status: The current status of the deployment. + replica_states: A map indicating how many replicas there are of + each replica state. + message: A message describing the deployment status in more + detail. + """ + + status: DeploymentStatus + status_trigger: DeploymentStatusTrigger + replica_states: Dict[ReplicaState, int] + message: str + + +@PublicAPI(stability="stable") +class ApplicationStatus(str, Enum): + """The current status of the application.""" + + NOT_STARTED = "NOT_STARTED" + DEPLOYING = "DEPLOYING" + DEPLOY_FAILED = "DEPLOY_FAILED" + RUNNING = "RUNNING" + UNHEALTHY = "UNHEALTHY" + DELETING = "DELETING" + + +@PublicAPI(stability="alpha") +@dataclass +class ApplicationStatusOverview: + """Describes the status of an application and all its deployments. + + Attributes: + status: The current status of the application. + message: A message describing the application status in more + detail. + last_deployed_time_s: The time at which the application was + deployed. A Unix timestamp in seconds. + deployments: The deployments in this application. + """ + + status: ApplicationStatus + message: str + last_deployed_time_s: float + deployments: Dict[str, DeploymentStatusOverview] + + +@PublicAPI(stability="alpha") +@dataclass(eq=True) +class ServeStatus: + """Describes the status of Serve. + + Attributes: + proxies: The proxy actors running on each node in the cluster. + A map from node ID to proxy status. + applications: The live applications in the cluster. + target_capacity: the target capacity percentage for all replicas across the + cluster. + """ + + proxies: Dict[str, ProxyStatus] = field(default_factory=dict) + applications: Dict[str, ApplicationStatusOverview] = field(default_factory=dict) + target_capacity: Optional[float] = TARGET_CAPACITY_FIELD + + +@PublicAPI(stability="stable") +class ServeActorDetails(BaseModel, frozen=True): + """Detailed info about a Ray Serve actor. + + Attributes: + node_id: ID of the node that the actor is running on. + node_ip: IP address of the node that the actor is running on. + node_instance_id: Cloud provider instance id of the node that the actor is running on. + actor_id: Actor ID. + actor_name: Actor name. + worker_id: Worker ID. + log_file_path: The relative path to the Serve actor's log file from the ray logs + directory. + """ + + node_id: Optional[str] = Field( + description="ID of the node that the actor is running on." + ) + node_ip: Optional[str] = Field( + description="IP address of the node that the actor is running on." + ) + node_instance_id: Optional[str] = Field( + description="Cloud provider instance id of the node that the actor is running on." + ) + actor_id: Optional[str] = Field(description="Actor ID.") + actor_name: Optional[str] = Field(description="Actor name.") + worker_id: Optional[str] = Field(description="Worker ID.") + log_file_path: Optional[str] = Field( + description=( + "The relative path to the Serve actor's log file from the ray logs " + "directory." + ) + ) + + +@PublicAPI(stability="stable") +class ReplicaDetails(ServeActorDetails, frozen=True): + """Detailed info about a single deployment replica.""" + + replica_id: str = Field(description="Unique ID for the replica.") + state: ReplicaState = Field(description="Current state of the replica.") + pid: Optional[int] = Field(description="PID of the replica actor process.") + start_time_s: float = Field( + description=( + "The time at which the replica actor was started. If the controller dies, " + "this is the time at which the controller recovers and retrieves replica " + "state from the running replica actor." + ) + ) + + +@PublicAPI(stability="alpha") +class AutoscalingMetricsHealth(str, Enum): + HEALTHY = "healthy" + DELAYED = "delayed" + UNAVAILABLE = "unavailable" + + +@PublicAPI(stability="alpha") +class AutoscalingStatus(str, Enum): + UPSCALING = "UPSCALING" + DOWNSCALING = "DOWNSCALING" + STABLE = "STABLE" + + +@PublicAPI(stability="alpha") +class ScalingDecision(BaseModel): + """One autoscaling decision with minimal provenance.""" + + timestamp_s: float = Field( + ..., description="Unix time (seconds) when the decision was made." + ) + reason: str = Field( + ..., description="Short, human-readable reason for the decision." + ) + prev_num_replicas: int = Field( + ..., ge=0, description="Replica count before the decision." + ) + curr_num_replicas: int = Field( + ..., ge=0, description="Replica count after the decision." + ) + policy: Optional[str] = Field( + None, description="Policy name or identifier (if applicable)." + ) + + +@PublicAPI(stability="alpha") +class DeploymentAutoscalingDetail(BaseModel): + """Deployment-level autoscaler observability.""" + + scaling_status: AutoscalingStatus = Field( + ..., description="Current scaling direction or stability." + ) + decisions: List[ScalingDecision] = Field( + default_factory=list, description="Recent scaling decisions." + ) + metrics: Optional[Dict[str, Any]] = Field( + None, description="Aggregated metrics for this deployment." + ) + metrics_health: AutoscalingMetricsHealth = Field( + AutoscalingMetricsHealth.HEALTHY, + description="Health of metrics collection pipeline.", + ) + errors: List[str] = Field( + default_factory=list, description="Recent errors/abnormal events." + ) + + +@PublicAPI(stability="stable") +class DeploymentDetails(BaseModel, extra=Extra.forbid, frozen=True): + """ + Detailed info about a deployment within a Serve application. + """ + + name: str = Field(description="Deployment name.") + status: DeploymentStatus = Field( + description="The current status of the deployment." + ) + status_trigger: DeploymentStatusTrigger = Field( + description="[EXPERIMENTAL] The trigger for the current status.", + ) + message: str = Field( + description=( + "If there are issues with the deployment, this will describe the issue in " + "more detail." + ) + ) + deployment_config: DeploymentSchema = Field( + description=( + "The set of deployment config options that are currently applied to this " + "deployment. These options may come from the user's code, config file " + "options, or Serve default values." + ) + ) + target_num_replicas: NonNegativeInt = Field( + description=( + "The current target number of replicas for this deployment. This can " + "change over time for autoscaling deployments, but will remain a constant " + "number for other deployments." + ) + ) + required_resources: Dict = Field( + description="The resources required per replica of this deployment." + ) + replicas: List[ReplicaDetails] = Field( + description="Details about the live replicas of this deployment." + ) + + autoscaling_detail: Optional[DeploymentAutoscalingDetail] = Field( + default=None, + description="[EXPERIMENTAL] Deployment-level autoscaler observability for this deployment.", + ) + + +@PublicAPI(stability="alpha") +class APIType(str, Enum): + """Tracks the type of API that an application originates from.""" + + UNKNOWN = "unknown" + IMPERATIVE = "imperative" + DECLARATIVE = "declarative" + + @classmethod + def get_valid_user_values(cls): + """Get list of valid APIType values that users can explicitly pass. + + Excludes 'unknown' which is for internal use only. + """ + return [cls.IMPERATIVE.value, cls.DECLARATIVE.value] + + +@PublicAPI(stability="stable") +class ApplicationDetails(BaseModel, extra=Extra.forbid, frozen=True): + """Detailed info about a Serve application.""" + + name: str = Field(description="Application name.") + route_prefix: Optional[str] = Field( + ..., + description=( + "This is the `route_prefix` of the ingress deployment in the application. " + "Requests to paths under this HTTP path prefix will be routed to this " + "application. This value may be null if the application is deploying " + "and app information has not yet fully propagated in the backend; or " + "if the user explicitly set the prefix to `None`, so the application isn't " + "exposed over HTTP. Routing is done based on longest-prefix match, so if " + 'you have deployment A with a prefix of "/a" and deployment B with a ' + 'prefix of "/a/b", requests to "/a", "/a/", and "/a/c" go to A and ' + 'requests to "/a/b", "/a/b/", and "/a/b/c" go to B. Routes must not end ' + 'with a "/" unless they\'re the root (just "/"), which acts as a catch-all.' + ), + ) + docs_path: Optional[str] = Field( + ..., + description=( + "The path at which the docs for this application is served, for instance " + "the `docs_url` for FastAPI-integrated applications." + ), + ) + status: ApplicationStatus = Field( + description="The current status of the application." + ) + message: str = Field( + description="A message that gives more insight into the application status." + ) + last_deployed_time_s: float = Field( + description="The time at which the application was deployed." + ) + deployed_app_config: Optional[ServeApplicationSchema] = Field( + description=( + "The exact copy of the application config that was submitted to the " + "cluster. This will include all of, and only, the options that were " + "explicitly specified in the submitted config. Default values for " + "unspecified options will not be displayed, and deployments that are part " + "of the application but unlisted in the config will also not be displayed. " + "Note that default values for unspecified options are applied to the " + "cluster under the hood, and deployments that were unlisted will still be " + "deployed. This config simply avoids cluttering with unspecified fields " + "for readability." + ) + ) + source: APIType = Field( + description=( + "The type of API that the application originates from. " + "This is a Developer API that is subject to change." + ), + ) + deployments: Dict[str, DeploymentDetails] = Field( + description="Details about the deployments in this application." + ) + + application_details_route_prefix_format = validator( + "route_prefix", allow_reuse=True + )(_route_prefix_format) + + +@PublicAPI(stability="stable") +class ProxyDetails(ServeActorDetails, frozen=True): + """Detailed info about a Ray Serve ProxyActor. + + Attributes: + status: The current status of the proxy. + """ + + status: ProxyStatus = Field(description="Current status of the proxy.") + + +@PublicAPI(stability="alpha") +class Target(BaseModel, frozen=True): + ip: str = Field(description="IP address of the target.") + port: int = Field(description="Port of the target.") + instance_id: str = Field(description="Instance ID of the target.") + name: str = Field(description="Name of the target.") + + +@PublicAPI(stability="alpha") +class TargetGroup(BaseModel, frozen=True): + targets: List[Target] = Field(description="List of targets for the given route.") + route_prefix: str = Field(description="Prefix route of the targets.") + protocol: RequestProtocol = Field(description="Protocol of the targets.") + + +@PublicAPI(stability="stable") +class ServeInstanceDetails(BaseModel, extra=Extra.forbid): + """ + Serve metadata with system-level info and details on all applications deployed to + the Ray cluster. + + This is the response JSON schema for v2 REST API `GET /api/serve/applications`. + """ + + controller_info: ServeActorDetails = Field( + description="Details about the Serve controller actor." + ) + proxy_location: Optional[ProxyLocation] = Field( + description=( + "Config for where to run proxies for ingress traffic to the cluster.\n" + '- "Disabled": disable the proxies entirely.\n' + '- "HeadOnly": run only one proxy on the head node.\n' + '- "EveryNode": run proxies on every node that has at least one replica.\n' + ), + ) + http_options: Optional[HTTPOptionsSchema] = Field(description="HTTP Proxy options.") + grpc_options: Optional[gRPCOptionsSchema] = Field(description="gRPC Proxy options.") + proxies: Dict[str, ProxyDetails] = Field( + description=( + "Mapping from node_id to details about the Proxy running on that node." + ) + ) + deploy_mode: ServeDeployMode = Field( + default=ServeDeployMode.MULTI_APP, + description=( + "[DEPRECATED]: single-app configs are removed, so this is always " + "MULTI_APP. This field will be removed in a future release." + ), + ) + applications: Dict[str, ApplicationDetails] = Field( + description="Details about all live applications running on the cluster." + ) + target_capacity: Optional[float] = TARGET_CAPACITY_FIELD + + target_groups: List[TargetGroup] = Field( + default_factory=list, + description=( + "List of target groups, each containing target info for a given route and " + "protocol." + ), + ) + + @staticmethod + def get_empty_schema_dict() -> Dict: + """Empty Serve instance details dictionary. + + Represents no Serve instance running on the cluster. + """ + + return { + "deploy_mode": "MULTI_APP", + "controller_info": {}, + "proxies": {}, + "applications": {}, + "target_capacity": None, + } + + def _get_status(self) -> ServeStatus: + return ServeStatus( + target_capacity=self.target_capacity, + proxies={node_id: proxy.status for node_id, proxy in self.proxies.items()}, + applications={ + app_name: ApplicationStatusOverview( + status=app.status, + message=app.message, + last_deployed_time_s=app.last_deployed_time_s, + deployments={ + deployment_name: DeploymentStatusOverview( + status=deployment.status, + status_trigger=deployment.status_trigger, + replica_states=dict( + Counter([r.state.value for r in deployment.replicas]) + ), + message=deployment.message, + ) + for deployment_name, deployment in app.deployments.items() + }, + ) + for app_name, app in self.applications.items() + }, + ) + + def _get_user_facing_json_serializable_dict( + self, *args, **kwargs + ) -> Dict[str, Any]: + """Generates json serializable dictionary with user facing data.""" + values = super().dict(*args, **kwargs) + + # `serialized_policy_def` and internal router config fields are only used + # internally and should not be exposed to the REST api. This method iteratively + # removes them from each deployment config if exists. + for app_name, application in values["applications"].items(): + for deployment_name, deployment in application["deployments"].items(): + if "deployment_config" in deployment: + # Remove internal fields from request_router_config if it exists + if "request_router_config" in deployment["deployment_config"]: + deployment["deployment_config"]["request_router_config"].pop( + "_serialized_request_router_cls", None + ) + if "autoscaling_config" in deployment["deployment_config"]: + deployment["deployment_config"]["autoscaling_config"].pop( + "_serialized_policy_def", None + ) + + return values + + +@PublicAPI(stability="alpha") +class CeleryAdapterConfig(BaseModel): + """ + Celery adapter config. You can use it to configure the Celery task processor for your Serve application. + """ + + app_custom_config: Optional[Dict[str, Any]] = Field( + default=None, description="The custom configurations to use for the Celery app." + ) + task_custom_config: Optional[Dict[str, Any]] = Field( + default=None, + description=""" + The custom configurations to use for the Celery task. + This custom configurations will get applied to all the celery tasks. + """, + ) + broker_url: str = Field(..., description="The URL of the broker to use for Celery.") + backend_url: str = Field( + ..., description="The URL of the backend to use for Celery." + ) + broker_transport_options: Optional[Dict[str, Any]] = Field( + default=None, description="The broker transport options to use for Celery." + ) + + +@PublicAPI(stability="alpha") +class TaskProcessorConfig(BaseModel): + """ + Task processor config. You can use it to configure the task processor for your Serve application. + """ + + queue_name: str = Field( + ..., description="The name of the queue to use for task processing." + ) + adapter: Union[str, Callable] = Field( + default="ray.serve.task_processor.CeleryTaskProcessorAdapter", + description="The adapter to use for task processing. By default, Celery is used.", + ) + adapter_config: Any = Field(..., description="The adapter config.") + max_retries: Optional[int] = Field( + default=3, + description="The maximum number of times to retry a task before marking it as failed.", + ) + failed_task_queue_name: Optional[str] = Field( + default=None, + description="The name of the failed task queue. This is used to move failed tasks to a dead-letter queue after max retries.", + ) + unprocessable_task_queue_name: Optional[str] = Field( + default=None, + description="The name of the unprocessable task queue. This is used to move unprocessable tasks(like tasks with serialization issue, or missing handler) to a dead-letter queue.", + ) + + +@PublicAPI(stability="alpha") +class TaskResult(BaseModel): + """ + Task result Model. + """ + + id: str = Field(..., description="The ID of the task.") + status: str = Field(..., description="The status of the task.") + created_at: Optional[float] = Field( + default=None, description="The timestamp of the task creation." + ) + result: Any = Field(..., description="The result of the task.") + + +@PublicAPI(stability="alpha") +class TaskProcessorAdapter(ABC): + """ + Abstract base class for task processing adapters. + + Subclasses can support different combinations of sync and async operations. + Use supports_async_capability() to check if a specific async operation is supported. + """ + + def __init__(self, *args, **kwargs): + """ + Initialize the TaskProcessorAdapter. + + """ + pass + + @abstractmethod + def initialize(self, consumer_concurrency: int = DEFAULT_CONSUMER_CONCURRENCY): + """ + Initialize the task processor. + """ + pass + + @abstractmethod + def register_task_handle(self, func: Callable, name: Optional[str] = None): + """ + Register a function as a task handler. + + Args: + func: The function to register as a task handler. + name: Custom name for the task. + """ + pass + + @abstractmethod + def enqueue_task_sync( + self, + task_name: str, + args: Optional[Any] = None, + kwargs: Optional[Any] = None, + **options, + ) -> TaskResult: + """ + Enqueue a task for execution synchronously. + + Args: + task_name: Name of the registered task to execute. + args: Positional arguments to pass to the task function. + kwargs: Keyword arguments to pass to the task function. + **options: Additional adapter-specific options for task execution. + + Returns: + TaskResult: Object containing task ID, status, and other metadata. + """ + pass + + @abstractmethod + def get_task_status_sync(self, task_id: str) -> TaskResult: + """ + Retrieve the current status of a task synchronously. + + Args: + task_id: Unique identifier of the task to query. + + Returns: + TaskResult: Object containing current task status, result, and other metadata. + """ + pass + + @abstractmethod + def start_consumer(self, **kwargs): + """ + Start the task consumer/worker process. + """ + pass + + @abstractmethod + def stop_consumer(self, timeout: float = 10.0): + """ + Stop the task consumer gracefully. + + Args: + timeout: Maximum time in seconds to wait for the consumer to stop. + """ + pass + + @abstractmethod + def shutdown(self): + """ + Shutdown the task processor and clean up resources. + """ + pass + + @abstractmethod + def cancel_task_sync(self, task_id: str): + """ + Cancel a task synchronously. + + Args: + task_id: Unique identifier of the task to cancel. + """ + pass + + @abstractmethod + def get_metrics_sync(self) -> Dict[str, Any]: + """ + Get metrics synchronously. + + Returns: + Dict[str, Any]: Adapter-specific metrics data. + """ + pass + + @abstractmethod + def health_check_sync(self) -> List[Dict]: + """ + Perform health check synchronously. + + Returns: + List[Dict]: Health status information for workers/components. + """ + pass + + async def enqueue_task_async( + self, + task_name: str, + args: Optional[Any] = None, + kwargs: Optional[Any] = None, + **options, + ) -> TaskResult: + """ + Enqueue a task asynchronously. + + Args: + task_name: Name of the registered task to execute. + args: Positional arguments to pass to the task function. + kwargs: Keyword arguments to pass to the task function. + **options: Additional adapter-specific options for task execution. + + Returns: + TaskResult: Object containing task ID, status, and other metadata. + + Raises: + NotImplementedError: If subclass didn't implement enqueue_task_async function + """ + + raise NotImplementedError("Subclass must implement enqueue_task_async function") + + async def get_task_status_async(self, task_id: str) -> TaskResult: + """ + Get task status asynchronously. + + Args: + task_id: Unique identifier of the task to query. + + Returns: + TaskResult: Object containing current task status, result, and other metadata. + + Raises: + NotImplementedError: If subclass didn't implement get_task_status_async function + """ + + raise NotImplementedError( + "Subclass must implement get_task_status_async function" + ) + + async def cancel_task_async(self, task_id: str): + """ + Cancel a task. + + Args: + task_id: Unique identifier of the task to cancel. + + Raises: + NotImplementedError: If subclass didn't implement cancel_task_async function + """ + + raise NotImplementedError("Subclass must implement cancel_task_async function") + + async def get_metrics_async(self) -> Dict[str, Any]: + """ + Get metrics asynchronously. + + Returns: + Dict[str, Any]: Adapter-specific metrics data. + + Raises: + NotImplementedError: If subclass didn't implement get_metrics_async function + """ + + raise NotImplementedError("Subclass must implement get_metrics_async function") + + async def health_check_async(self) -> List[Dict]: + """ + Perform health check asynchronously. + + Returns: + List[Dict]: Health status information for workers/components. + + Raises: + NotImplementedError: If subclass didn't implement health_check_async function + """ + + raise NotImplementedError("Subclass must implement health_check_async function") + + +@PublicAPI(stability="alpha") +class ScaleDeploymentRequest(BaseModel): + """Request schema for scaling a deployment's replicas.""" + + target_num_replicas: NonNegativeInt = Field( + description="The target number of replicas for the deployment." + ) + + +@PublicAPI(stability="alpha") +class ReplicaRank(BaseModel): + """Replica rank model.""" + + rank: int = Field( + description="Global rank of the replica across all nodes scoped to the deployment." + ) + + node_rank: int = Field(description="Rank of the node in the deployment.") + + local_rank: int = Field( + description="Rank of the replica on the node scoped to the deployment." + ) diff --git a/lib/python3.12/site-packages/ray/serve/scripts.py b/lib/python3.12/site-packages/ray/serve/scripts.py new file mode 100644 index 0000000000000000000000000000000000000000..7e232a9d9d121ef5cca446765ad7bd950549ed6f --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/scripts.py @@ -0,0 +1,923 @@ +#!/usr/bin/env python +import os +import pathlib +import re +import sys +import time +import traceback +from dataclasses import asdict +from typing import Any, Dict, List, Optional, Tuple + +import click +import watchfiles +import yaml + +import ray +from ray import serve +from ray._common.utils import import_attr +from ray.autoscaler._private.cli_logger import cli_logger +from ray.dashboard.modules.dashboard_sdk import parse_runtime_env_args +from ray.dashboard.modules.serve.sdk import ServeSubmissionClient +from ray.serve._private import api as _private_api +from ray.serve._private.build_app import BuiltApplication, build_app +from ray.serve._private.constants import ( + DEFAULT_GRPC_PORT, + DEFAULT_HTTP_HOST, + DEFAULT_HTTP_PORT, + SERVE_DEFAULT_APP_NAME, + SERVE_NAMESPACE, +) +from ray.serve.config import ( + DeploymentMode, + ProxyLocation, + gRPCOptions, +) +from ray.serve.deployment import Application, deployment_to_schema +from ray.serve.schema import ( + LoggingConfig, + ServeApplicationSchema, + ServeDeploySchema, + ServeInstanceDetails, +) + +APP_DIR_HELP_STR = ( + "Local directory to look for the IMPORT_PATH (will be inserted into " + "PYTHONPATH). Defaults to '.', meaning that an object in ./main.py " + "can be imported as 'main.object'. Not relevant if you're importing " + "from an installed module." +) +RAY_INIT_ADDRESS_HELP_STR = ( + "Address to use for ray.init(). Can also be set using " + "the RAY_ADDRESS environment variable." +) +RAY_DASHBOARD_ADDRESS_HELP_STR = ( + "Address for the Ray dashboard. Defaults to http://localhost:8265. " + "Can also be set using the RAY_DASHBOARD_ADDRESS environment variable." +) + + +# See https://stackoverflow.com/a/33300001/11162437 +def str_presenter(dumper: yaml.Dumper, data): + """ + A custom representer to write multi-line strings in block notation using a literal + style. + + Ensures strings with newline characters print correctly. + """ + + if len(data.splitlines()) > 1: + return dumper.represent_scalar("tag:yaml.org,2002:str", data, style="|") + return dumper.represent_scalar("tag:yaml.org,2002:str", data) + + +# See https://stackoverflow.com/a/14693789/11162437 +def remove_ansi_escape_sequences(input: str): + """Removes ANSI escape sequences in a string""" + ansi_escape = re.compile( + r""" + \x1B # ESC + (?: # 7-bit C1 Fe (except CSI) + [@-Z\\-_] + | # or [ for CSI, followed by a control sequence + \[ + [0-?]* # Parameter bytes + [ -/]* # Intermediate bytes + [@-~] # Final byte + ) + """, + re.VERBOSE, + ) + + return ansi_escape.sub("", input) + + +def process_dict_for_yaml_dump(data): + """ + Removes ANSI escape sequences recursively for all strings in dict. + + We often need to use yaml.dump() to print dictionaries that contain exception + tracebacks, which can contain ANSI escape sequences that color printed text. However + yaml.dump() will format the tracebacks incorrectly if ANSI escape sequences are + present, so we need to remove them before dumping. + """ + + for k, v in data.items(): + if isinstance(v, dict): + data[k] = process_dict_for_yaml_dump(v) + if isinstance(v, list): + data[k] = [process_dict_for_yaml_dump(item) for item in v] + elif isinstance(v, str): + data[k] = remove_ansi_escape_sequences(v) + + return data + + +def convert_args_to_dict(args: Tuple[str]) -> Dict[str, str]: + args_dict = dict() + for arg in args: + split = arg.split("=", maxsplit=1) + if len(split) != 2 or len(split[1]) == 0: + raise click.ClickException( + f"Invalid application argument '{arg}', " + "must be of the form '<key>=<val>'." + ) + + args_dict[split[0]] = split[1] + + return args_dict + + +def warn_if_agent_address_set(): + if "RAY_AGENT_ADDRESS" in os.environ: + cli_logger.warning( + "The `RAY_AGENT_ADDRESS` env var has been deprecated in favor of " + "the `RAY_DASHBOARD_ADDRESS` env var. The `RAY_AGENT_ADDRESS` is " + "ignored." + ) + + +@click.group( + help="CLI for managing Serve applications on a Ray cluster.", + context_settings=dict(help_option_names=["--help", "-h"]), +) +def cli(): + pass + + +@cli.command(help="Start Serve on the Ray cluster.") +@click.option( + "--address", + "-a", + default=os.environ.get("RAY_ADDRESS", "auto"), + required=False, + type=str, + help=RAY_INIT_ADDRESS_HELP_STR, +) +@click.option( + "--http-host", + default=DEFAULT_HTTP_HOST, + required=False, + type=str, + help="Host for HTTP proxies to listen on. " f"Defaults to {DEFAULT_HTTP_HOST}.", +) +@click.option( + "--http-port", + default=DEFAULT_HTTP_PORT, + required=False, + type=int, + help="Port for HTTP proxies to listen on. " f"Defaults to {DEFAULT_HTTP_PORT}.", +) +@click.option( + "--http-location", + default=DeploymentMode.HeadOnly, + required=False, + type=click.Choice(list(DeploymentMode)), + help="DEPRECATED: Use `--proxy-location` instead.", +) +@click.option( + "--proxy-location", + default=ProxyLocation.EveryNode, + required=False, + type=click.Choice(list(ProxyLocation)), + help="Location of the proxies. Defaults to EveryNode.", +) +@click.option( + "--grpc-port", + default=DEFAULT_GRPC_PORT, + required=False, + type=int, + help="Port for gRPC proxies to listen on. " f"Defaults to {DEFAULT_GRPC_PORT}.", +) +@click.option( + "--grpc-servicer-functions", + default=[], + required=False, + multiple=True, + help="Servicer function for adding the method handler to the gRPC server. " + "Defaults to an empty list and no gRPC server is started.", +) +def start( + address, + http_host, + http_port, + http_location, + proxy_location, + grpc_port, + grpc_servicer_functions, +): + if http_location != DeploymentMode.HeadOnly: + cli_logger.warning( + "The `--http-location` flag to `serve start` is deprecated, " + "use `--proxy-location` instead." + ) + + proxy_location = http_location + + ray.init( + address=address, + namespace=SERVE_NAMESPACE, + ) + serve.start( + proxy_location=proxy_location, + http_options=dict( + host=http_host, + port=http_port, + ), + grpc_options=gRPCOptions( + port=grpc_port, + grpc_servicer_functions=grpc_servicer_functions, + ), + ) + + +def _generate_config_from_file_or_import_path( + config_or_import_path: str, + *, + name: Optional[str], + arguments: Dict[str, str], + runtime_env: Optional[Dict[str, Any]], +) -> ServeDeploySchema: + """Generates a deployable config schema for the passed application(s).""" + if pathlib.Path(config_or_import_path).is_file(): + config_path = config_or_import_path + cli_logger.print(f"Deploying from config file: '{config_path}'.") + if len(arguments) > 0: + raise click.ClickException( + "Application arguments cannot be specified for a config file." + ) + + # TODO(edoakes): should we enable overriding? + with open(config_path, "r") as config_file: + if runtime_env and len(runtime_env) > 0: + cli_logger.warning( + "Passed in runtime_env is ignored when using config file" + ) + if name is not None: + cli_logger.warning("Passed in name is ignored when using config file") + config_dict = yaml.safe_load(config_file) + config = ServeDeploySchema.parse_obj(config_dict) + else: + # TODO(edoakes): should we default to --working-dir="." for this? + import_path = config_or_import_path + cli_logger.print(f"Deploying from import path: '{import_path}'.") + + app = ServeApplicationSchema( + import_path=import_path, + runtime_env=runtime_env, + args=arguments, + ) + if name is not None: + app.name = name + config = ServeDeploySchema(applications=[app]) + + return config + + +@cli.command( + short_help="Deploy an application or group of applications.", + help=( + "Deploy an application from an import path (e.g., main:app) " + "or a group of applications from a YAML config file.\n\n" + "Passed import paths must point to an Application object or " + "a function that returns one. If a function is used, arguments can be " + "passed to it in 'key=val' format after the import path, for example:\n\n" + "serve deploy main:app model_path='/path/to/model.pkl' num_replicas=5\n\n" + "This command makes a REST API request to a running Ray cluster." + ), +) +@click.argument("config_or_import_path") +@click.argument("arguments", nargs=-1, required=False) +@click.option( + "--runtime-env", + type=str, + default=None, + required=False, + help=( + "Path to a local YAML file containing a runtime_env definition. Ignored " + "when deploying from a config file." + ), +) +@click.option( + "--runtime-env-json", + type=str, + default=None, + required=False, + help=( + "JSON-serialized runtime_env dictionary. Ignored when deploying from a " + "config file." + ), +) +@click.option( + "--working-dir", + type=str, + default=None, + required=False, + help=( + "Directory containing files that your application(s) will run in. This must " + "be a remote URI to a .zip file (e.g., S3 bucket). This overrides the " + "working_dir in --runtime-env if both are specified. Ignored when deploying " + "from a config file." + ), +) +@click.option( + "--name", + required=False, + default=None, + type=str, + help="Custom name for the application. Ignored when deploying from a config file.", +) +@click.option( + "--address", + "-a", + default=os.environ.get("RAY_DASHBOARD_ADDRESS", "http://localhost:8265"), + required=False, + type=str, + help=RAY_DASHBOARD_ADDRESS_HELP_STR, +) +def deploy( + config_or_import_path: str, + arguments: Tuple[str], + runtime_env: str, + runtime_env_json: str, + working_dir: str, + name: Optional[str], + address: str, +): + args_dict = convert_args_to_dict(arguments) + final_runtime_env = parse_runtime_env_args( + runtime_env=runtime_env, + runtime_env_json=runtime_env_json, + working_dir=working_dir, + ) + + config = _generate_config_from_file_or_import_path( + config_or_import_path, + name=name, + arguments=args_dict, + runtime_env=final_runtime_env, + ) + + ServeSubmissionClient(address).deploy_applications( + config.dict(exclude_unset=True), + ) + cli_logger.success( + "\nSent deploy request successfully.\n " + "* Use `serve status` to check applications' statuses.\n " + "* Use `serve config` to see the current application config(s).\n" + ) + + +@cli.command( + short_help="Run an application or group of applications.", + help=( + "Run an application from an import path (e.g., my_script:" + "app) or a group of applications from a YAML config file.\n\n" + "Passed import paths must point to an Application object or " + "a function that returns one. If a function is used, arguments can be " + "passed to it in 'key=val' format after the import path, for example:\n\n" + "serve run my_script:app model_path='/path/to/model.pkl' num_replicas=5\n\n" + "If passing a YAML config, existing applications with no code changes will not " + "be updated.\n\n" + "By default, this will block and stream logs to the console. If you " + "Ctrl-C the command, it will shut down Serve on the cluster." + ), +) +@click.argument("config_or_import_path") +@click.argument("arguments", nargs=-1, required=False) +@click.option( + "--runtime-env", + type=str, + default=None, + required=False, + help="Path to a local YAML file containing a runtime_env definition. " + "This will be passed to ray.init() as the default for deployments.", +) +@click.option( + "--runtime-env-json", + type=str, + default=None, + required=False, + help="JSON-serialized runtime_env dictionary. This will be passed to " + "ray.init() as the default for deployments.", +) +@click.option( + "--working-dir", + type=str, + default=None, + required=False, + help=( + "Directory containing files that your application(s) will run in. Can be a " + "local directory or a remote URI to a .zip file (S3, GS, HTTP). " + "This overrides the working_dir in --runtime-env if both are " + "specified. This will be passed to ray.init() as the default for " + "deployments." + ), +) +@click.option( + "--app-dir", + "-d", + default=".", + type=str, + help=APP_DIR_HELP_STR, +) +@click.option( + "--address", + "-a", + default=os.environ.get("RAY_ADDRESS", None), + required=False, + type=str, + help=RAY_INIT_ADDRESS_HELP_STR, +) +@click.option( + "--blocking/--non-blocking", + default=True, + help=( + "Whether or not this command should be blocking. If blocking, it " + "will loop and log status until Ctrl-C'd, then clean up the app." + ), +) +@click.option( + "--reload", + "-r", + is_flag=True, + help=( + "This is an experimental feature - Listens for changes to files in the working directory, " + "--working-dir or the working_dir in the --runtime-env, and automatically redeploys " + "the application. This will block until Ctrl-C'd, then clean up the " + "app." + ), +) +@click.option( + "--route-prefix", + required=False, + type=str, + default="/", + help=( + "Route prefix for the application. This should only be used " + "when running an application specified by import path and " + "will be ignored if running a config file." + ), +) +@click.option( + "--name", + required=False, + default=SERVE_DEFAULT_APP_NAME, + type=str, + help=( + "Name of the application. This should only be used " + "when running an application specified by import path and " + "will be ignored if running a config file." + ), +) +def run( + config_or_import_path: str, + arguments: Tuple[str], + runtime_env: str, + runtime_env_json: str, + working_dir: str, + app_dir: str, + address: str, + blocking: bool, + reload: bool, + route_prefix: str, + name: str, +): + sys.path.insert(0, app_dir) + args_dict = convert_args_to_dict(arguments) + final_runtime_env = parse_runtime_env_args( + runtime_env=runtime_env, + runtime_env_json=runtime_env_json, + working_dir=working_dir, + ) + + if pathlib.Path(config_or_import_path).is_file(): + if len(args_dict) > 0: + cli_logger.warning( + "Application arguments are ignored when running a config file." + ) + + is_config = True + config_path = config_or_import_path + cli_logger.print(f"Running config file: '{config_path}'.") + + with open(config_path, "r") as config_file: + config_dict = yaml.safe_load(config_file) + + config = ServeDeploySchema.parse_obj(config_dict) + + else: + is_config = False + import_path = config_or_import_path + cli_logger.print(f"Running import path: '{import_path}'.") + app = _private_api.call_user_app_builder_with_args_if_necessary( + import_attr(import_path), args_dict + ) + + # Only initialize ray if it has not happened yet. + if not ray.is_initialized(): + # Setting the runtime_env here will set defaults for the deployments. + ray.init( + address=address, namespace=SERVE_NAMESPACE, runtime_env=final_runtime_env + ) + elif ( + address is not None + and address != "auto" + and address != ray.get_runtime_context().gcs_address + ): + # Warning users the address they passed is different from the existing ray + # instance. + ray_address = ray.get_runtime_context().gcs_address + cli_logger.warning( + "An address was passed to `serve run` but the imported module also " + f"connected to Ray at a different address: '{ray_address}'. You do not " + "need to call `ray.init` in your code when using `serve run`." + ) + + http_options = {"location": "EveryNode"} + grpc_options = gRPCOptions() + # Merge http_options and grpc_options with the ones on ServeDeploySchema. + if is_config and isinstance(config, ServeDeploySchema): + http_options["location"] = ProxyLocation._to_deployment_mode( + config.proxy_location + ).value + config_http_options = config.http_options.dict() + http_options = {**config_http_options, **http_options} + grpc_options = gRPCOptions(**config.grpc_options.dict()) + + client = _private_api.serve_start( + http_options=http_options, + grpc_options=grpc_options, + ) + + try: + if is_config: + client.deploy_apps(config, _blocking=False) + cli_logger.success("Submitted deploy config successfully.") + if blocking: + while True: + # Block, letting Ray print logs to the terminal. + time.sleep(10) + else: + # This should not block if reload is true so the watchfiles can be triggered + should_block = blocking and not reload + serve.run(app, blocking=should_block, name=name, route_prefix=route_prefix) + + if reload: + if not blocking: + raise click.ClickException( + "The --non-blocking option conflicts with the --reload option." + ) + if working_dir: + watch_dir = working_dir + else: + watch_dir = app_dir + + for changes in watchfiles.watch( + watch_dir, + rust_timeout=10000, + yield_on_timeout=True, + ): + if changes: + try: + # The module needs to be reloaded with `importlib` in order to + # pick up any changes. + app = _private_api.call_user_app_builder_with_args_if_necessary( + import_attr(import_path, reload_module=True), args_dict + ) + serve.run( + target=app, + blocking=False, + name=name, + route_prefix=route_prefix, + ) + except Exception: + traceback.print_exc() + cli_logger.error( + "Deploying the latest version of the application failed." + ) + + except KeyboardInterrupt: + cli_logger.info("Got KeyboardInterrupt, shutting down...") + serve.shutdown() + sys.exit() + + except Exception: + traceback.print_exc() + cli_logger.error( + "Received unexpected error, see console logs for more details. Shutting " + "down..." + ) + serve.shutdown() + sys.exit() + + +@cli.command(help="Gets the current configs of Serve applications on the cluster.") +@click.option( + "--address", + "-a", + default=os.environ.get("RAY_DASHBOARD_ADDRESS", "http://localhost:8265"), + required=False, + type=str, + help=RAY_DASHBOARD_ADDRESS_HELP_STR, +) +@click.option( + "--name", + "-n", + required=False, + type=str, + help=( + "Name of an application. Only applies to multi-application mode. If set, this " + "will only fetch the config for the specified application." + ), +) +def config(address: str, name: Optional[str]): + warn_if_agent_address_set() + + serve_details = ServeInstanceDetails( + **ServeSubmissionClient(address).get_serve_details() + ) + applications = serve_details.applications + + # Fetch app configs for all live applications on the cluster + if name is None: + configs = [ + yaml.safe_dump( + app.deployed_app_config.dict(exclude_unset=True), + sort_keys=False, + ) + for app in applications.values() + if app.deployed_app_config is not None + ] + if configs: + print("\n---\n\n".join(configs), end="") + else: + print("No configuration was found.") + # Fetch a specific app config by name. + else: + app = applications.get(name) + if app is None or app.deployed_app_config is None: + print(f'No config has been deployed for application "{name}".') + else: + config = app.deployed_app_config.dict(exclude_unset=True) + print(yaml.safe_dump(config, sort_keys=False), end="") + + +@cli.command( + short_help="Get the current status of all Serve applications on the cluster.", + help=( + "Prints status information about all applications on the cluster.\n\n" + "An application may be:\n\n" + "- NOT_STARTED: the application does not exist.\n" + "- DEPLOYING: the deployments in the application are still deploying and " + "haven't reached the target number of replicas.\n" + "- RUNNING: all deployments are healthy.\n" + "- DEPLOY_FAILED: the application failed to deploy or reach a running state.\n" + "- DELETING: the application is being deleted, and the deployments in the " + "application are being teared down.\n\n" + "The deployments within each application may be:\n\n" + "- HEALTHY: all replicas are acting normally and passing their health checks.\n" + "- UNHEALTHY: at least one replica is not acting normally and may not be " + "passing its health check.\n" + "- UPDATING: the deployment is updating." + ), +) +@click.option( + "--address", + "-a", + default=os.environ.get("RAY_DASHBOARD_ADDRESS", "http://localhost:8265"), + required=False, + type=str, + help=RAY_DASHBOARD_ADDRESS_HELP_STR, +) +@click.option( + "--name", + "-n", + default=None, + required=False, + type=str, + help=( + "Name of an application. If set, this will display only the status of the " + "specified application." + ), +) +def status(address: str, name: Optional[str]): + warn_if_agent_address_set() + + serve_details = ServeInstanceDetails( + **ServeSubmissionClient(address).get_serve_details() + ) + status = asdict(serve_details._get_status()) + + # Ensure multi-line strings in app_status is dumped/printed correctly + yaml.SafeDumper.add_representer(str, str_presenter) + + if name is None: + print( + yaml.safe_dump( + # Ensure exception traceback in app_status are printed correctly + process_dict_for_yaml_dump(status), + default_flow_style=False, + sort_keys=False, + ), + end="", + ) + else: + if name not in serve_details.applications: + cli_logger.error(f'Application "{name}" does not exist.') + else: + print( + yaml.safe_dump( + # Ensure exception tracebacks in app_status are printed correctly + process_dict_for_yaml_dump(status["applications"][name]), + default_flow_style=False, + sort_keys=False, + ), + end="", + ) + + +@cli.command( + help="Shuts down Serve on the cluster, deleting all applications.", +) +@click.option( + "--address", + "-a", + default=os.environ.get("RAY_DASHBOARD_ADDRESS", "http://localhost:8265"), + required=False, + type=str, + help=RAY_DASHBOARD_ADDRESS_HELP_STR, +) +@click.option("--yes", "-y", is_flag=True, help="Bypass confirmation prompt.") +def shutdown(address: str, yes: bool): + warn_if_agent_address_set() + + # check if the address is a valid Ray address + try: + # see what applications are deployed on the cluster + serve_details = ServeInstanceDetails( + **ServeSubmissionClient(address).get_serve_details() + ) + if serve_details.controller_info.node_id is None: + cli_logger.warning( + f"No Serve instance found running on the cluster at {address}." + ) + return + except Exception as e: + cli_logger.error( + f"Unable to shutdown Serve on the cluster at address {address}: {e}" + ) + return + + if not yes: + click.confirm( + f"This will shut down Serve on the cluster at address " + f'"{address}" and delete all applications there. Do you ' + "want to continue?", + abort=True, + ) + + ServeSubmissionClient(address).delete_applications() + + cli_logger.success( + "Sent shutdown request; applications will be deleted asynchronously." + ) + + +@cli.command( + short_help="Generate a config file for the specified applications.", + help=( + "Imports the applications at IMPORT_PATHS and generates a structured, multi-" + "application config for them. If the flag --single-app is set, accepts one " + "application and generates a single-application config. Config " + "outputted from this command can be used by `serve deploy` or the REST API. " + ), +) +@click.argument("import_paths", nargs=-1, required=True) +@click.option( + "--app-dir", + "-d", + default=".", + type=str, + help=APP_DIR_HELP_STR, +) +@click.option( + "--output-path", + "-o", + default=None, + type=str, + help=( + "Local path where the output config will be written in YAML format. " + "If not provided, the config will be printed to STDOUT." + ), +) +@click.option( + "--grpc-servicer-functions", + default=[], + required=False, + multiple=True, + help="Servicer function for adding the method handler to the gRPC server. " + "Defaults to an empty list and no gRPC server is started.", +) +def build( + import_paths: Tuple[str], + app_dir: str, + output_path: Optional[str], + grpc_servicer_functions: List[str], +): + sys.path.insert(0, app_dir) + + def build_app_config(import_path: str, name: str = None): + app: Application = import_attr(import_path) + if not isinstance(app, Application): + raise TypeError( + f"Expected '{import_path}' to be an Application but got {type(app)}." + ) + + built_app: BuiltApplication = build_app(app, name=name) + schema = ServeApplicationSchema( + name=name, + route_prefix="/" if len(import_paths) == 1 else f"/{name}", + import_path=import_path, + runtime_env={}, + deployments=[deployment_to_schema(d) for d in built_app.deployments], + ) + + return schema.dict(exclude_unset=True) + + config_str = ( + "# This file was generated using the `serve build` command " + f"on Ray v{ray.__version__}.\n\n" + ) + + app_configs = [] + for app_index, import_path in enumerate(import_paths): + app_configs.append(build_app_config(import_path, name=f"app{app_index + 1}")) + + deploy_config = { + "proxy_location": "EveryNode", + "http_options": { + "host": "0.0.0.0", + "port": 8000, + }, + "grpc_options": { + "port": DEFAULT_GRPC_PORT, + "grpc_servicer_functions": grpc_servicer_functions, + }, + "logging_config": LoggingConfig().dict(), + "applications": app_configs, + } + + # Parse + validate the set of application configs + ServeDeploySchema.parse_obj(deploy_config) + + config_str += yaml.dump( + deploy_config, + Dumper=ServeDeploySchemaDumper, + default_flow_style=False, + sort_keys=False, + width=80, # Set width to avoid folding long lines + indent=2, # Use 2-space indentation for more compact configuration + ) + cli_logger.info( + "The auto-generated application names default to `app1`, `app2`, ... etc. " + "Rename as necessary.\n", + ) + + # Ensure file ends with only one newline + config_str = config_str.rstrip("\n") + "\n" + + with open(output_path, "w") if output_path else sys.stdout as f: + f.write(config_str) + + +class ServeDeploySchemaDumper(yaml.SafeDumper): + """YAML dumper object with custom formatting for ServeDeploySchema. + + Reformat config to follow this spacing with appropriate line breaks: + --------------------------------------------------------------- + proxy_location: EveryNode + + http_options: + host: 0.0.0.0 + port: 8000 + + grpc_options: + port: 9000 + grpc_servicer_functions: [] + + logging_config: + # ... + + applications: + - name: app1 + import_path: app1.path + # ... + """ + + def write_line_break(self, data=None): + # https://github.com/yaml/pyyaml/issues/127#issuecomment-525800484 + super().write_line_break(data) + + # Only add extra line breaks between top-level keys + if len(self.indents) == 1: + super().write_line_break() diff --git a/lib/python3.12/site-packages/ray/serve/task_consumer.py b/lib/python3.12/site-packages/ray/serve/task_consumer.py new file mode 100644 index 0000000000000000000000000000000000000000..b172eec24b0d8824588180e0626514f65814a673 --- /dev/null +++ b/lib/python3.12/site-packages/ray/serve/task_consumer.py @@ -0,0 +1,224 @@ +import inspect +import logging +from functools import wraps +from typing import Callable, Optional + +from ray._common.utils import import_attr +from ray.serve._private.constants import ( + DEFAULT_CONSUMER_CONCURRENCY, + SERVE_LOGGER_NAME, +) +from ray.serve._private.task_consumer import TaskConsumerWrapper +from ray.serve.schema import ( + TaskProcessorAdapter, + TaskProcessorConfig, +) +from ray.util.annotations import PublicAPI + +logger = logging.getLogger(SERVE_LOGGER_NAME) + + +def _instantiate_adapter( + task_processor_config: TaskProcessorConfig, + consumer_concurrency: int = DEFAULT_CONSUMER_CONCURRENCY, +) -> TaskProcessorAdapter: + adapter = task_processor_config.adapter + + # Handle string-based adapter specification (module path) + if isinstance(adapter, str): + adapter_class = import_attr(adapter) + + elif callable(adapter): + adapter_class = adapter + + else: + raise TypeError( + f"Adapter must be either a string path or a callable class, got {type(adapter).__name__}: {adapter}" + ) + + try: + adapter_instance = adapter_class(task_processor_config) + except Exception as e: + raise RuntimeError(f"Failed to instantiate {adapter_class.__name__}: {e}") + + if not isinstance(adapter_instance, TaskProcessorAdapter): + raise TypeError( + f"{adapter_class.__name__} must inherit from TaskProcessorAdapter, got {type(adapter_instance).__name__}" + ) + + try: + adapter_instance.initialize(consumer_concurrency) + except Exception as e: + raise RuntimeError(f"Failed to initialize {adapter_class.__name__}: {e}") + + return adapter_instance + + +@PublicAPI(stability="alpha") +def instantiate_adapter_from_config( + task_processor_config: TaskProcessorConfig, +) -> TaskProcessorAdapter: + """ + Create a TaskProcessorAdapter instance from the provided configuration and call .initialize(). This function supports two ways to specify an adapter: + + 1. String path: A fully qualified module path to an adapter class + Example: "ray.serve.task_processor.CeleryTaskProcessorAdapter" + + 2. Class reference: A direct reference to an adapter class + Example: CeleryTaskProcessorAdapter + + Args: + task_processor_config: Configuration object containing adapter specification. + Returns: + An initialized TaskProcessorAdapter instance ready for use. + + Raises: + ValueError: If the adapter string path is malformed or cannot be imported. + TypeError: If the adapter is not a string or callable class. + + Example: + .. code-block:: python + + config = TaskProcessorConfig( + adapter="my.module.CustomAdapter", + adapter_config={"param": "value"}, + queue_name="my_queue" + ) + adapter = instantiate_adapter_from_config(config) + """ + + return _instantiate_adapter(task_processor_config) + + +@PublicAPI(stability="alpha") +def task_consumer(*, task_processor_config: TaskProcessorConfig): + """ + Decorator to mark a class as a TaskConsumer. + + Args: + task_processor_config: Configuration for the task processor (required) + + Note: + This decorator must be used with parentheses: + @task_consumer(task_processor_config=config) + + Returns: + A wrapper class that inherits from the target class and implements the task consumer functionality. + + Example: + .. code-block:: python + + from ray import serve + from ray.serve.task_consumer import task_consumer, task_handler + + @serve.deployment + @task_consumer(task_processor_config=config) + class MyTaskConsumer: + + @task_handler(name="my_task") + def my_task(self, *args, **kwargs): + pass + + """ + + def decorator(target_cls): + class _TaskConsumerWrapper(target_cls, TaskConsumerWrapper): + _adapter: TaskProcessorAdapter + + def __init__(self, *args, **kwargs): + target_cls.__init__(self, *args, **kwargs) + + def initialize_callable(self, consumer_concurrency: int): + self._adapter = _instantiate_adapter( + task_processor_config, consumer_concurrency + ) + + for name, method in inspect.getmembers( + target_cls, predicate=inspect.isfunction + ): + if getattr(method, "_is_task_handler", False): + task_name = getattr(method, "_task_name", name) + + # Create a callable that properly binds the method to this instance + bound_method = getattr(self, name) + + self._adapter.register_task_handle(bound_method, task_name) + + try: + self._adapter.start_consumer() + logger.info("task consumer started successfully") + except Exception as e: + logger.error(f"Failed to start task consumer: {e}") + raise + + def __del__(self): + self._adapter.stop_consumer() + self._adapter.shutdown() + + if hasattr(target_cls, "__del__"): + target_cls.__del__(self) + + # Preserve the original class name + _TaskConsumerWrapper.__name__ = target_cls.__name__ + + return _TaskConsumerWrapper + + return decorator + + +@PublicAPI(stability="alpha") +def task_handler( + _func: Optional[Callable] = None, *, name: Optional[str] = None +) -> Callable: + """ + Decorator to mark a method as a task handler. + Optionally specify a task name. Default is the method name. + + Arguments: + _func: The function to decorate. + name: The name of the task. Default is the method name. + + Returns: + A wrapper function that is marked as a task handler. + + Example: + .. code-block:: python + + from ray import serve + from ray.serve.task_consumer import task_consumer, task_handler + + @serve.deployment + @task_consumer(task_processor_config=config) + class MyTaskConsumer: + + @task_handler(name="my_task") + def my_task(self, *args, **kwargs): + pass + + """ + + # Validate name parameter if provided + if name is not None and (not isinstance(name, str) or not name.strip()): + raise ValueError(f"Task name must be a non-empty string, got {name}") + + def decorator(f): + # async functions are not supported yet in celery `threads` worker pool + if not inspect.iscoroutinefunction(f): + + @wraps(f) + def wrapper(*args, **kwargs): + return f(*args, **kwargs) + + wrapper._is_task_handler = True # type: ignore + wrapper._task_name = name or f.__name__ # type: ignore + return wrapper + + else: + raise NotImplementedError("Async task handlers are not supported yet") + + if _func is not None: + # Used without arguments: @task_handler + return decorator(_func) + else: + # Used with arguments: @task_handler(name="...") + return decorator diff --git a/lib/python3.12/site-packages/ray/widgets/__init__.py b/lib/python3.12/site-packages/ray/widgets/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..2479501640ba3f2b0be30c3548ee465a13a481a9 --- /dev/null +++ b/lib/python3.12/site-packages/ray/widgets/__init__.py @@ -0,0 +1,4 @@ +from ray.widgets.render import Template +from ray.widgets.util import make_table_html_repr + +__all__ = ["Template", "make_table_html_repr"] diff --git a/lib/python3.12/site-packages/ray/widgets/__pycache__/__init__.cpython-312.pyc b/lib/python3.12/site-packages/ray/widgets/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4f033e230349bb9d5f906a3bebf7ff4c549a8406 Binary files /dev/null and b/lib/python3.12/site-packages/ray/widgets/__pycache__/__init__.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/widgets/__pycache__/render.cpython-312.pyc b/lib/python3.12/site-packages/ray/widgets/__pycache__/render.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..22dc472215cf9d7561dbdc3dd363b29655ca5630 Binary files /dev/null and b/lib/python3.12/site-packages/ray/widgets/__pycache__/render.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/widgets/__pycache__/util.cpython-312.pyc b/lib/python3.12/site-packages/ray/widgets/__pycache__/util.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..dc1de2c0a538d0a42a13cfef38775dcc1aa18fa1 Binary files /dev/null and b/lib/python3.12/site-packages/ray/widgets/__pycache__/util.cpython-312.pyc differ diff --git a/lib/python3.12/site-packages/ray/widgets/render.py b/lib/python3.12/site-packages/ray/widgets/render.py new file mode 100644 index 0000000000000000000000000000000000000000..f9e861d39925680c403ff996e9279d4d349bafe5 --- /dev/null +++ b/lib/python3.12/site-packages/ray/widgets/render.py @@ -0,0 +1,39 @@ +import pathlib +from typing import List + +from ray.util.annotations import DeveloperAPI + + +@DeveloperAPI +class Template: + """Class which provides basic HTML templating.""" + + def __init__(self, file: str): + with open(pathlib.Path(__file__).parent / "templates" / file, "r") as f: + self.template = f.read() + + def render(self, **kwargs) -> str: + """Render an HTML template with the given data. + + This is done by replacing instances of `{{ key }}` with `value` + from the keyword arguments. + + Returns: + HTML template with the keys of the kwargs replaced with corresponding + values. + """ + rendered = self.template + for key, value in kwargs.items(): + if isinstance(value, List): + value = "".join(value) + rendered = rendered.replace("{{ " + key + " }}", value if value else "") + return rendered + + @staticmethod + def list_templates() -> List[pathlib.Path]: + """List the available HTML templates. + + Returns: + A list of files with .html.j2 extensions inside ../templates/ + """ + return (pathlib.Path(__file__).parent / "templates").glob("*.html.j2") diff --git a/lib/python3.12/site-packages/ray/widgets/templates/context.html.j2 b/lib/python3.12/site-packages/ray/widgets/templates/context.html.j2 new file mode 100644 index 0000000000000000000000000000000000000000..26cc0ef6c8784c9859151f8553b240d7a94e9360 --- /dev/null +++ b/lib/python3.12/site-packages/ray/widgets/templates/context.html.j2 @@ -0,0 +1,6 @@ +<div class="lm-Widget p-Widget lm-Panel p-Panel jp-Cell-outputWrapper"> + <div style="margin-left: 50px;display: flex;flex-direction: row;align-items: center"> + {{ context_logo }} + {{ context_table }} + </div> +</div> diff --git a/lib/python3.12/site-packages/ray/widgets/templates/context_dashrow.html.j2 b/lib/python3.12/site-packages/ray/widgets/templates/context_dashrow.html.j2 new file mode 100644 index 0000000000000000000000000000000000000000..47fbc2fa6f6da7e8a44c88c0e07ae51c6a737dbd --- /dev/null +++ b/lib/python3.12/site-packages/ray/widgets/templates/context_dashrow.html.j2 @@ -0,0 +1,4 @@ +<tr> + <td style="text-align: left"><b>Dashboard:</b></td> + <td style="text-align: left"><b><a href="{{ dashboard_url }}" target="_blank">{{ dashboard_url }}</a></b></td> +</tr> diff --git a/lib/python3.12/site-packages/ray/widgets/templates/context_logo.html.j2 b/lib/python3.12/site-packages/ray/widgets/templates/context_logo.html.j2 new file mode 100644 index 0000000000000000000000000000000000000000..9233fe3a77226c760442d5366b8d5fb65b93abcd --- /dev/null +++ b/lib/python3.12/site-packages/ray/widgets/templates/context_logo.html.j2 @@ -0,0 +1,13 @@ +<div class="jp-RenderedHTMLCommon" style="display: flex; flex-direction: row;"> + <svg viewBox="0 0 567 224" fill="none" xmlns="http://www.w3.org/2000/svg" style="height: 3em;"> + <g clip-path="url(#clip0_4338_178347)"> + <path d="M341.29 165.561H355.29L330.13 129.051C345.63 123.991 354.21 112.051 354.21 94.2307C354.21 71.3707 338.72 58.1807 311.88 58.1807H271V165.561H283.27V131.661H311.8C314.25 131.661 316.71 131.501 319.01 131.351L341.25 165.561H341.29ZM283.29 119.851V70.0007H311.82C331.3 70.0007 342.34 78.2907 342.34 94.5507C342.34 111.271 331.34 119.861 311.82 119.861L283.29 119.851ZM451.4 138.411L463.4 165.561H476.74L428.74 58.1807H416L367.83 165.561H380.83L392.83 138.411H451.4ZM446.19 126.601H398L422 72.1407L446.24 126.601H446.19ZM526.11 128.741L566.91 58.1807H554.35L519.99 114.181L485.17 58.1807H472.44L514.01 129.181V165.541H526.13V128.741H526.11Z" fill="var(--jp-ui-font-color0)"/> + <path d="M82.35 104.44C84.0187 97.8827 87.8248 92.0678 93.1671 87.9146C98.5094 83.7614 105.083 81.5067 111.85 81.5067C118.617 81.5067 125.191 83.7614 130.533 87.9146C135.875 92.0678 139.681 97.8827 141.35 104.44H163.75C164.476 101.562 165.622 98.8057 167.15 96.2605L127.45 56.5605C121.071 60.3522 113.526 61.6823 106.235 60.3005C98.9443 58.9187 92.4094 54.9203 87.8602 49.0574C83.3109 43.1946 81.0609 35.8714 81.5332 28.4656C82.0056 21.0599 85.1679 14.0819 90.4252 8.8446C95.6824 3.60726 102.672 0.471508 110.08 0.0272655C117.487 -0.416977 124.802 1.86091 130.647 6.4324C136.493 11.0039 140.467 17.5539 141.821 24.8501C143.175 32.1463 141.816 39.6859 138 46.0505L177.69 85.7505C182.31 82.9877 187.58 81.4995 192.962 81.4375C198.345 81.3755 203.648 82.742 208.33 85.3976C213.012 88.0532 216.907 91.9029 219.616 96.5544C222.326 101.206 223.753 106.492 223.753 111.875C223.753 117.258 222.326 122.545 219.616 127.197C216.907 131.848 213.012 135.698 208.33 138.353C203.648 141.009 198.345 142.375 192.962 142.313C187.58 142.251 182.31 140.763 177.69 138L138 177.7C141.808 184.071 143.155 191.614 141.79 198.91C140.424 206.205 136.44 212.75 130.585 217.313C124.731 221.875 117.412 224.141 110.004 223.683C102.596 223.226 95.6103 220.077 90.3621 214.828C85.1139 209.58 81.9647 202.595 81.5072 195.187C81.0497 187.779 83.3154 180.459 87.878 174.605C92.4405 168.751 98.9853 164.766 106.281 163.401C113.576 162.035 121.119 163.383 127.49 167.19L167.19 127.49C165.664 124.941 164.518 122.182 163.79 119.3H141.39C139.721 125.858 135.915 131.673 130.573 135.826C125.231 139.98 118.657 142.234 111.89 142.234C105.123 142.234 98.5494 139.98 93.2071 135.826C87.8648 131.673 84.0587 125.858 82.39 119.3H60C58.1878 126.495 53.8086 132.78 47.6863 136.971C41.5641 141.163 34.1211 142.972 26.7579 142.059C19.3947 141.146 12.6191 137.574 7.70605 132.014C2.79302 126.454 0.0813599 119.29 0.0813599 111.87C0.0813599 104.451 2.79302 97.2871 7.70605 91.7272C12.6191 86.1673 19.3947 82.5947 26.7579 81.6817C34.1211 80.7686 41.5641 82.5781 47.6863 86.7696C53.8086 90.9611 58.1878 97.2456 60 104.44H82.35ZM100.86 204.32C103.407 206.868 106.759 208.453 110.345 208.806C113.93 209.159 117.527 208.258 120.522 206.256C123.517 204.254 125.725 201.276 126.771 197.828C127.816 194.38 127.633 190.677 126.253 187.349C124.874 184.021 122.383 181.274 119.205 179.577C116.027 177.88 112.359 177.337 108.826 178.042C105.293 178.746 102.113 180.654 99.8291 183.44C97.5451 186.226 96.2979 189.718 96.3 193.32C96.2985 195.364 96.7006 197.388 97.4831 199.275C98.2656 201.163 99.4132 202.877 100.86 204.32ZM204.32 122.88C206.868 120.333 208.453 116.981 208.806 113.396C209.159 109.811 208.258 106.214 206.256 103.219C204.254 100.223 201.275 98.0151 197.827 96.97C194.38 95.9249 190.676 96.1077 187.348 97.4873C184.02 98.8669 181.274 101.358 179.577 104.536C177.879 107.714 177.337 111.382 178.041 114.915C178.746 118.448 180.653 121.627 183.439 123.911C186.226 126.195 189.717 127.443 193.32 127.44C195.364 127.443 197.388 127.042 199.275 126.259C201.163 125.476 202.878 124.328 204.32 122.88ZM122.88 19.4205C120.333 16.8729 116.981 15.2876 113.395 14.9347C109.81 14.5817 106.213 15.483 103.218 17.4849C100.223 19.4868 98.0146 22.4654 96.9696 25.9131C95.9245 29.3608 96.1073 33.0642 97.4869 36.3922C98.8665 39.7202 101.358 42.4668 104.535 44.1639C107.713 45.861 111.381 46.4036 114.914 45.6992C118.447 44.9949 121.627 43.0871 123.911 40.301C126.195 37.515 127.442 34.0231 127.44 30.4205C127.44 28.3772 127.038 26.3539 126.255 24.4664C125.473 22.5788 124.326 20.8642 122.88 19.4205ZM19.42 100.86C16.8725 103.408 15.2872 106.76 14.9342 110.345C14.5813 113.93 15.4826 117.527 17.4844 120.522C19.4863 123.518 22.4649 125.726 25.9127 126.771C29.3604 127.816 33.0638 127.633 36.3918 126.254C39.7198 124.874 42.4664 122.383 44.1635 119.205C45.8606 116.027 46.4032 112.359 45.6988 108.826C44.9944 105.293 43.0866 102.114 40.3006 99.8296C37.5145 97.5455 34.0227 96.2983 30.42 96.3005C26.2938 96.3018 22.337 97.9421 19.42 100.86ZM100.86 100.86C98.3125 103.408 96.7272 106.76 96.3742 110.345C96.0213 113.93 96.9226 117.527 98.9244 120.522C100.926 123.518 103.905 125.726 107.353 126.771C110.8 127.816 114.504 127.633 117.832 126.254C121.16 124.874 123.906 122.383 125.604 119.205C127.301 116.027 127.843 112.359 127.139 108.826C126.434 105.293 124.527 102.114 121.741 99.8296C118.955 97.5455 115.463 96.2983 111.86 96.3005C109.817 96.299 107.793 96.701 105.905 97.4835C104.018 98.2661 102.303 99.4136 100.86 100.86Z" fill="#00AEEF"/> + </g> + <defs> + <clipPath id="clip0_4338_178347"> + <rect width="566.93" height="223.75" fill="white"/> + </clipPath> + </defs> + </svg> +</div> diff --git a/lib/python3.12/site-packages/ray/widgets/templates/context_table.html.j2 b/lib/python3.12/site-packages/ray/widgets/templates/context_table.html.j2 new file mode 100644 index 0000000000000000000000000000000000000000..d06822d0c1f56534e5009ea2ff81340fdc6f7850 --- /dev/null +++ b/lib/python3.12/site-packages/ray/widgets/templates/context_table.html.j2 @@ -0,0 +1,11 @@ +<table class="jp-RenderedHTMLCommon" style="border-collapse: collapse;color: var(--jp-ui-font-color1);font-size: var(--jp-ui-font-size1);"> + <tr> + <td style="text-align: left"><b>Python version:</b></td> + <td style="text-align: left"><b>{{ python_version }}</b></td> + </tr> + <tr> + <td style="text-align: left"><b>Ray version:</b></td> + <td style="text-align: left"><b>{{ ray_version }}</b></td> + </tr> + {{ dashboard_row }} +</table> diff --git a/lib/python3.12/site-packages/ray/widgets/templates/divider.html.j2 b/lib/python3.12/site-packages/ray/widgets/templates/divider.html.j2 new file mode 100644 index 0000000000000000000000000000000000000000..b9a04173d7e0918c54a1d950da25938a215be3a2 --- /dev/null +++ b/lib/python3.12/site-packages/ray/widgets/templates/divider.html.j2 @@ -0,0 +1,9 @@ +<div class="vDivider"></div> +<style> +.vDivider { + border-left-width: var(--jp-border-width); + border-left-color: var(--jp-border-color0); + border-left-style: solid; + margin: 0.5em 1em 0.5em 1em; +} +</style> diff --git a/lib/python3.12/site-packages/ray/widgets/templates/rendered_html_common.html.j2 b/lib/python3.12/site-packages/ray/widgets/templates/rendered_html_common.html.j2 new file mode 100644 index 0000000000000000000000000000000000000000..35b4cee0133a3b4105e84ca7a66a2ffeb01f4c5b --- /dev/null +++ b/lib/python3.12/site-packages/ray/widgets/templates/rendered_html_common.html.j2 @@ -0,0 +1,3 @@ +<div class='jp-RenderedHTMLCommon'> + {{ content }} +</div> diff --git a/lib/python3.12/site-packages/ray/widgets/templates/run_config.html.j2 b/lib/python3.12/site-packages/ray/widgets/templates/run_config.html.j2 new file mode 100644 index 0000000000000000000000000000000000000000..4cf392dff519cdd5d7c0629ca3394f47ef705825 --- /dev/null +++ b/lib/python3.12/site-packages/ray/widgets/templates/run_config.html.j2 @@ -0,0 +1,18 @@ +<div class="runConfig"> + <div class="generalSettings"> + {{ settings }} + </div> + <div class="sideBySide"> + {{ subconfigs }} + </div> +</div> +<style> +.sideBySide { + display: flex; + flex-direction: row; + gap: 1em; +} +.generalSettings { + border-bottom: var(--jp-border-width) solid var(--jp-border-color0); +} +</style> diff --git a/lib/python3.12/site-packages/ray/widgets/templates/scrollableTable.html.j2 b/lib/python3.12/site-packages/ray/widgets/templates/scrollableTable.html.j2 new file mode 100644 index 0000000000000000000000000000000000000000..2ec1637b92ee7de3d22bba0a956b8539464c0501 --- /dev/null +++ b/lib/python3.12/site-packages/ray/widgets/templates/scrollableTable.html.j2 @@ -0,0 +1,20 @@ +<div class="scrollableTable jp-RenderedHTMLCommon"> + {{ table }} +</div> +<style> +.scrollableTable { + overflow-y: auto; + max-height: {{ max_height }}; +} +.scrollableTable table { + width: 100%; +} +.scrollableTable table :is(th,td) { + text-align: left !important; +} +.scrollableTable th { + background: var(--jp-layout-color1); + position: sticky; + top: 0; +} +</style> diff --git a/lib/python3.12/site-packages/ray/widgets/templates/title_data.html.j2 b/lib/python3.12/site-packages/ray/widgets/templates/title_data.html.j2 new file mode 100644 index 0000000000000000000000000000000000000000..1731a157d17b4674e3d7cc427164065dafe49de2 --- /dev/null +++ b/lib/python3.12/site-packages/ray/widgets/templates/title_data.html.j2 @@ -0,0 +1,11 @@ +<div class='titleData jp-RenderedHTMLCommon'> + <h3>{{ title }}</h3> + {{ data }} +</div> +<style> +.titleData h3 { + border-bottom-width: var(--jp-border-width); + border-bottom-color: var(--jp-border-color0); + border-bottom-style: solid; +} +</style> diff --git a/lib/python3.12/site-packages/ray/widgets/templates/title_data_mini.html.j2 b/lib/python3.12/site-packages/ray/widgets/templates/title_data_mini.html.j2 new file mode 100644 index 0000000000000000000000000000000000000000..bfe654c56346b09a1999dda379d129aaf1e317be --- /dev/null +++ b/lib/python3.12/site-packages/ray/widgets/templates/title_data_mini.html.j2 @@ -0,0 +1,4 @@ +<div class='miniTitleData jp-RenderedHTMLCommon'> + <h4><b>{{ title }}</b></h4> + {{ data }} +</div> diff --git a/lib/python3.12/site-packages/ray/widgets/templates/trial_progress.html.j2 b/lib/python3.12/site-packages/ray/widgets/templates/trial_progress.html.j2 new file mode 100644 index 0000000000000000000000000000000000000000..f3a323193e7fa242f9cc37a75d22d02841b74da6 --- /dev/null +++ b/lib/python3.12/site-packages/ray/widgets/templates/trial_progress.html.j2 @@ -0,0 +1,17 @@ +<div class="trialProgress"> + <h3>Trial Progress</h3> + {{ table }} +</div> +<style> +.trialProgress { + display: flex; + flex-direction: column; + color: var(--jp-ui-font-color1); +} +.trialProgress h3 { + font-weight: bold; +} +.trialProgress td { + white-space: nowrap; +} +</style> diff --git a/lib/python3.12/site-packages/ray/widgets/templates/tune_status.html.j2 b/lib/python3.12/site-packages/ray/widgets/templates/tune_status.html.j2 new file mode 100644 index 0000000000000000000000000000000000000000..df422f89af4e7d03bc70006414f12652f5898824 --- /dev/null +++ b/lib/python3.12/site-packages/ray/widgets/templates/tune_status.html.j2 @@ -0,0 +1,49 @@ +<div class="tuneStatus"> + <div style="display: flex;flex-direction: row"> + <div style="display: flex;flex-direction: column;"> + <h3>Tune Status</h3> + {{ status_table }} + </div> + <div class="vDivider"></div> + <div class="systemInfo"> + <h3>System Info</h3> + {{ sys_info_message }} + </div> + {{ messages }} + </div> + <div class="hDivider"></div> + <div class="trialStatus"> + <h3>Trial Status</h3> + {{ trial_progress }} + </div> +</div> +<style> +.tuneStatus { + color: var(--jp-ui-font-color1); +} +.tuneStatus .systemInfo { + display: flex; + flex-direction: column; +} +.tuneStatus td { + white-space: nowrap; +} +.tuneStatus .trialStatus { + display: flex; + flex-direction: column; +} +.tuneStatus h3 { + font-weight: bold; +} +.tuneStatus .hDivider { + border-bottom-width: var(--jp-border-width); + border-bottom-color: var(--jp-border-color0); + border-bottom-style: solid; +} +.tuneStatus .vDivider { + border-left-width: var(--jp-border-width); + border-left-color: var(--jp-border-color0); + border-left-style: solid; + margin: 0.5em 1em 0.5em 1em; +} +</style> diff --git a/lib/python3.12/site-packages/ray/widgets/templates/tune_status_messages.html.j2 b/lib/python3.12/site-packages/ray/widgets/templates/tune_status_messages.html.j2 new file mode 100644 index 0000000000000000000000000000000000000000..da8e75f5f58d3a5c78274429e7490947aff4c410 --- /dev/null +++ b/lib/python3.12/site-packages/ray/widgets/templates/tune_status_messages.html.j2 @@ -0,0 +1,25 @@ +<div class="vDivider"></div> +<div class="messages"> + <h3>Messages</h3> + {{ memory_message }} + {{ trial_progress_messages }} + {{ trial_errors }} +</div> +<style> +.messages { + color: var(--jp-ui-font-color1); + display: flex; + flex-direction: column; + padding-left: 1em; + overflow-y: auto; +} +.messages h3 { + font-weight: bold; +} +.vDivider { + border-left-width: var(--jp-border-width); + border-left-color: var(--jp-border-color0); + border-left-style: solid; + margin: 0.5em 1em 0.5em 1em; +} +</style> diff --git a/lib/python3.12/site-packages/ray/widgets/util.py b/lib/python3.12/site-packages/ray/widgets/util.py new file mode 100644 index 0000000000000000000000000000000000000000..2f171c6519cde57fd69033e040a9305ebbbdf53b --- /dev/null +++ b/lib/python3.12/site-packages/ray/widgets/util.py @@ -0,0 +1,207 @@ +import importlib +import logging +import sys +import textwrap +from functools import wraps +from typing import Any, Callable, Iterable, Optional, TypeVar, Union + +from packaging.version import Version + +from ray._private.thirdparty.tabulate.tabulate import tabulate +from ray.util.annotations import DeveloperAPI +from ray.widgets import Template + +logger = logging.getLogger(__name__) + +F = TypeVar("F", bound=Callable[..., Any]) + + +@DeveloperAPI +def make_table_html_repr( + obj: Any, title: Optional[str] = None, max_height: str = "none" +) -> str: + """Generate a generic html repr using a table. + + Args: + obj: Object for which a repr is to be generated + title: If present, a title for the section is included + max_height: Maximum height of the table; valid values + are given by the max-height CSS property + + Returns: + HTML representation of the object + """ + data = {} + for k, v in vars(obj).items(): + if isinstance(v, (str, bool, int, float)): + data[k] = str(v) + + elif isinstance(v, dict) or hasattr(v, "__dict__"): + data[k] = Template("scrollableTable.html.j2").render( + table=tabulate( + v.items() if isinstance(v, dict) else vars(v).items(), + tablefmt="html", + showindex=False, + headers=["Setting", "Value"], + ), + max_height="none", + ) + + table = Template("scrollableTable.html.j2").render( + table=tabulate( + data.items(), + tablefmt="unsafehtml", + showindex=False, + headers=["Setting", "Value"], + ), + max_height=max_height, + ) + + if title: + content = Template("title_data.html.j2").render(title=title, data=table) + else: + content = table + + return content + + +def _has_missing( + *deps: Iterable[Union[str, Optional[str]]], message: Optional[str] = None +): + """Return a list of missing dependencies. + + Args: + deps: Dependencies to check for + message: Message to be emitted if a dependency isn't found + + Returns: + A list of dependencies which can't be found, if any + """ + missing = [] + for (lib, _) in deps: + if importlib.util.find_spec(lib) is None: + missing.append(lib) + + if missing: + if not message: + message = f"Run `pip install {' '.join(missing)}` for rich notebook output." + + # stacklevel=3: First level is this function, then ensure_notebook_deps, + # then the actual function affected. + logger.info(f"Missing packages: {missing}. {message}", stacklevel=3) + + return missing + + +def _has_outdated( + *deps: Iterable[Union[str, Optional[str]]], message: Optional[str] = None +): + outdated = [] + for (lib, version) in deps: + try: + + module = importlib.import_module(lib) + if version and Version(module.__version__) < Version(version): + outdated.append([lib, version, module.__version__]) + except ImportError: + pass + + if outdated: + outdated_strs = [] + install_args = [] + for lib, version, installed in outdated: + outdated_strs.append(f"{lib}=={installed} found, needs {lib}>={version}") + install_args.append(f"{lib}>={version}") + + outdated_str = textwrap.indent("\n".join(outdated_strs), " ") + install_str = " ".join(install_args) + + if not message: + message = f"Run `pip install -U {install_str}` for rich notebook output." + + # stacklevel=3: First level is this function, then ensure_notebook_deps, + # then the actual function affected. + logger.info(f"Outdated packages:\n{outdated_str}\n{message}", stacklevel=3) + + return outdated + + +@DeveloperAPI +def repr_with_fallback( + *notebook_deps: Iterable[Union[str, Optional[str]]] +) -> Callable[[F], F]: + """Decorator which strips rich notebook output from mimebundles in certain cases. + + Fallback to plaintext and don't use rich output in the following cases: + 1. In a notebook environment and the appropriate dependencies are not installed. + 2. In a ipython shell environment. + 3. In Google Colab environment. + See https://github.com/googlecolab/colabtools/ issues/60 for more information + about the status of this issue. + + Args: + notebook_deps: The required dependencies and version for notebook environment. + + Returns: + A function that returns the usual _repr_mimebundle_, unless any of the 3 + conditions above hold, in which case it returns a mimebundle that only contains + a single text/plain mimetype. + """ + message = ( + "Run `pip install -U ipywidgets`, then restart " + "the notebook server for rich notebook output." + ) + if _can_display_ipywidgets(*notebook_deps, message=message): + + def wrapper(func: F) -> F: + @wraps(func) + def wrapped(self, *args, **kwargs): + return func(self, *args, **kwargs) + + return wrapped + + else: + + def wrapper(func: F) -> F: + @wraps(func) + def wrapped(self, *args, **kwargs): + return {"text/plain": repr(self)} + + return wrapped + + return wrapper + + +def _get_ipython_shell_name() -> str: + if "IPython" in sys.modules: + from IPython import get_ipython + + return get_ipython().__class__.__name__ + return "" + + +def _can_display_ipywidgets(*deps, message) -> bool: + # Default to safe behavior: only display widgets if running in a notebook + # that has valid dependencies + if in_notebook() and not ( + _has_missing(*deps, message=message) or _has_outdated(*deps, message=message) + ): + return True + + return False + + +@DeveloperAPI +def in_notebook(shell_name: Optional[str] = None) -> bool: + """Return whether we are in a Jupyter notebook or qtconsole.""" + if not shell_name: + shell_name = _get_ipython_shell_name() + return shell_name == "ZMQInteractiveShell" + + +@DeveloperAPI +def in_ipython_shell(shell_name: Optional[str] = None) -> bool: + """Return whether we are in a terminal running IPython""" + if not shell_name: + shell_name = _get_ipython_shell_name() + return shell_name == "TerminalInteractiveShell" diff --git a/lib/python3.12/site-packages/torchgen/yaml_utils.py b/lib/python3.12/site-packages/torchgen/yaml_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..0278af84bf633a85a857b6bd7798dd64cb8259dc --- /dev/null +++ b/lib/python3.12/site-packages/torchgen/yaml_utils.py @@ -0,0 +1,26 @@ +# Safely load fast C Yaml loader/dumper if they are available +try: + from yaml import CSafeLoader as Loader +except ImportError: + from yaml import SafeLoader as Loader # type: ignore[assignment, misc] + +try: + from yaml import CSafeDumper as Dumper +except ImportError: + from yaml import SafeDumper as Dumper # type: ignore[assignment, misc] +YamlDumper = Dumper + + +# A custom loader for YAML that errors on duplicate keys. +# This doesn't happen by default: see https://github.com/yaml/pyyaml/issues/165 +class YamlLoader(Loader): + def construct_mapping(self, node, deep=False): # type: ignore[no-untyped-def] + mapping = [] + for key_node, value_node in node.value: + key = self.construct_object(key_node, deep=deep) # type: ignore[no-untyped-call] + assert ( + key not in mapping + ), f"Found a duplicate key in the yaml. key={key}, line={node.start_mark.line}" + mapping.append(key) + mapping = super().construct_mapping(node, deep=deep) # type: ignore[no-untyped-call] + return mapping