diff --git a/libs/Flask_Cors-4.0.0.dist-info/RECORD b/libs/Flask_Cors-4.0.0.dist-info/RECORD deleted file mode 100644 index eae9c8673..000000000 --- a/libs/Flask_Cors-4.0.0.dist-info/RECORD +++ /dev/null @@ -1,12 +0,0 @@ -Flask_Cors-4.0.0.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4 -Flask_Cors-4.0.0.dist-info/LICENSE,sha256=bhob3FSDTB4HQMvOXV9vLK4chG_Sp_SCsRZJWU-vvV0,1069 -Flask_Cors-4.0.0.dist-info/METADATA,sha256=gH5CIZManWT1lJuvM-YzOlSGJNdzB03QkylAtEc24tY,5417 -Flask_Cors-4.0.0.dist-info/RECORD,, -Flask_Cors-4.0.0.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 -Flask_Cors-4.0.0.dist-info/WHEEL,sha256=P2T-6epvtXQ2cBOE_U1K4_noqlJFN3tj15djMgEu4NM,110 -Flask_Cors-4.0.0.dist-info/top_level.txt,sha256=aWye_0QNZPp_QtPF4ZluLHqnyVLT9CPJsfiGhwqkWuo,11 -flask_cors/__init__.py,sha256=wZDCvPTHspA2g1VV7KyKN7R-uCdBnirTlsCzgPDcQtI,792 -flask_cors/core.py,sha256=e1u_o5SOcS_gMWGjcQrkyk91uPICnzZ3AXZvy5jQ_FE,14063 -flask_cors/decorator.py,sha256=BeJsyX1wYhVKWN04FAhb6z8YqffiRr7wKqwzHPap4bw,5009 -flask_cors/extension.py,sha256=nP4Zq_BhgDVWwPdIl_f-uucNxD38pXUo-dkL-voXc58,7832 -flask_cors/version.py,sha256=61rJjfThnbRdElpSP2tm31hPmFnHJmcwoPhtqA0Bi_Q,22 diff --git a/libs/Flask_Cors-4.0.0.dist-info/INSTALLER b/libs/Flask_Cors-5.0.0.dist-info/INSTALLER similarity index 100% rename from libs/Flask_Cors-4.0.0.dist-info/INSTALLER rename to libs/Flask_Cors-5.0.0.dist-info/INSTALLER diff --git a/libs/Flask_Cors-4.0.0.dist-info/LICENSE b/libs/Flask_Cors-5.0.0.dist-info/LICENSE similarity index 100% rename from libs/Flask_Cors-4.0.0.dist-info/LICENSE rename to libs/Flask_Cors-5.0.0.dist-info/LICENSE diff --git a/libs/Flask_Cors-4.0.0.dist-info/METADATA b/libs/Flask_Cors-5.0.0.dist-info/METADATA similarity index 89% rename from libs/Flask_Cors-4.0.0.dist-info/METADATA rename to libs/Flask_Cors-5.0.0.dist-info/METADATA index c6e2af695..99902fe9d 100644 --- a/libs/Flask_Cors-4.0.0.dist-info/METADATA +++ b/libs/Flask_Cors-5.0.0.dist-info/METADATA @@ -1,6 +1,6 @@ Metadata-Version: 2.1 Name: Flask-Cors -Version: 4.0.0 +Version: 5.0.0 Summary: A Flask extension adding a decorator for CORS support Home-page: https://github.com/corydolphin/flask-cors Author: Cory Dolphin @@ -16,6 +16,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.12 Classifier: Programming Language :: Python :: Implementation :: CPython Classifier: Programming Language :: Python :: Implementation :: PyPy Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content @@ -31,10 +32,10 @@ Flask-CORS A Flask extension for handling Cross Origin Resource Sharing (CORS), making cross-origin AJAX possible. -This package has a simple philosophy: when you want to enable CORS, you wish to enable it for all use cases on a domain. -This means no mucking around with different allowed headers, methods, etc. +This package has a simple philosophy: when you want to enable CORS, you wish to enable it for all use cases on a domain. +This means no mucking around with different allowed headers, methods, etc. -By default, submission of cookies across domains is disabled due to the security implications. +By default, submission of cookies across domains is disabled due to the security implications. Please see the documentation for how to enable credential'ed requests, and please make sure you add some sort of `CSRF `__ protection before doing so! Installation @@ -49,14 +50,14 @@ Install the extension with using pip, or easy\_install. Usage ----- -This package exposes a Flask extension which by default enables CORS support on all routes, for all origins and methods. -It allows parameterization of all CORS headers on a per-resource level. +This package exposes a Flask extension which by default enables CORS support on all routes, for all origins and methods. +It allows parameterization of all CORS headers on a per-resource level. The package also contains a decorator, for those who prefer this approach. Simple Usage ~~~~~~~~~~~~ -In the simplest case, initialize the Flask-Cors extension with default arguments in order to allow CORS for all domains on all routes. +In the simplest case, initialize the Flask-Cors extension with default arguments in order to allow CORS for all domains on all routes. See the full list of options in the `documentation `__. .. code:: python @@ -75,7 +76,7 @@ See the full list of options in the `documentation `__. .. code:: python @@ -90,8 +91,8 @@ See the full list of options in the `documentation `__. .. code:: python @@ -119,7 +120,7 @@ If things aren't working as you expect, enable logging to help understand what i Tests ----- -A simple set of tests is included in ``test/``. +A simple set of tests is included in ``test/``. To run, install nose, and simply invoke ``nosetests`` or ``python setup.py test`` to exercise the tests. If nosetests does not work for you, due to it no longer working with newer python versions. @@ -128,8 +129,8 @@ You can use pytest to run the tests instead. Contributing ------------ -Questions, comments or improvements? -Please create an issue on `Github `__, tweet at `@corydolphin `__ or send me an email. +Questions, comments or improvements? +Please create an issue on `Github `__, tweet at `@corydolphin `__ or send me an email. I do my best to include every contribution proposed in any way that I can. Credits @@ -137,7 +138,7 @@ Credits This Flask extension is based upon the `Decorator for the HTTP Access Control `__ written by Armin Ronacher. -.. |Build Status| image:: https://api.travis-ci.org/corydolphin/flask-cors.svg?branch=master +.. |Build Status| image:: https://github.com/corydolphin/flask-cors/actions/workflows/unittests.yaml/badge.svg :target: https://travis-ci.org/corydolphin/flask-cors .. |Latest Version| image:: https://img.shields.io/pypi/v/Flask-Cors.svg :target: https://pypi.python.org/pypi/Flask-Cors/ diff --git a/libs/Flask_Cors-5.0.0.dist-info/RECORD b/libs/Flask_Cors-5.0.0.dist-info/RECORD new file mode 100644 index 000000000..5e942ce53 --- /dev/null +++ b/libs/Flask_Cors-5.0.0.dist-info/RECORD @@ -0,0 +1,12 @@ +Flask_Cors-5.0.0.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4 +Flask_Cors-5.0.0.dist-info/LICENSE,sha256=bhob3FSDTB4HQMvOXV9vLK4chG_Sp_SCsRZJWU-vvV0,1069 +Flask_Cors-5.0.0.dist-info/METADATA,sha256=V2L_s849dFlZXsOhcgXVqv5Slj_JKSVuiiuRgDOft5s,5474 +Flask_Cors-5.0.0.dist-info/RECORD,, +Flask_Cors-5.0.0.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +Flask_Cors-5.0.0.dist-info/WHEEL,sha256=P2T-6epvtXQ2cBOE_U1K4_noqlJFN3tj15djMgEu4NM,110 +Flask_Cors-5.0.0.dist-info/top_level.txt,sha256=aWye_0QNZPp_QtPF4ZluLHqnyVLT9CPJsfiGhwqkWuo,11 +flask_cors/__init__.py,sha256=wZDCvPTHspA2g1VV7KyKN7R-uCdBnirTlsCzgPDcQtI,792 +flask_cors/core.py,sha256=y76xxLasWTdV_3ka19IxpdJPOgROBZQZ5L8t20IjqRA,14252 +flask_cors/decorator.py,sha256=BeJsyX1wYhVKWN04FAhb6z8YqffiRr7wKqwzHPap4bw,5009 +flask_cors/extension.py,sha256=gzv6zWUwSDYlGHBWzMuTI_hoQ7gQmp9DlcAcrKTVHdw,8602 +flask_cors/version.py,sha256=JzYPYpvaglqIJRGCDrh5-hYmXI0ISrDDed0V1QQZAGU,22 diff --git a/libs/Flask_Cors-4.0.0.dist-info/REQUESTED b/libs/Flask_Cors-5.0.0.dist-info/REQUESTED similarity index 100% rename from libs/Flask_Cors-4.0.0.dist-info/REQUESTED rename to libs/Flask_Cors-5.0.0.dist-info/REQUESTED diff --git a/libs/Flask_Cors-4.0.0.dist-info/WHEEL b/libs/Flask_Cors-5.0.0.dist-info/WHEEL similarity index 100% rename from libs/Flask_Cors-4.0.0.dist-info/WHEEL rename to libs/Flask_Cors-5.0.0.dist-info/WHEEL diff --git a/libs/Flask_Cors-4.0.0.dist-info/top_level.txt b/libs/Flask_Cors-5.0.0.dist-info/top_level.txt similarity index 100% rename from libs/Flask_Cors-4.0.0.dist-info/top_level.txt rename to libs/Flask_Cors-5.0.0.dist-info/top_level.txt diff --git a/libs/Flask_Migrate-4.0.5.dist-info/INSTALLER b/libs/Flask_Migrate-4.0.7.dist-info/INSTALLER similarity index 100% rename from libs/Flask_Migrate-4.0.5.dist-info/INSTALLER rename to libs/Flask_Migrate-4.0.7.dist-info/INSTALLER diff --git a/libs/Flask_Migrate-4.0.5.dist-info/LICENSE b/libs/Flask_Migrate-4.0.7.dist-info/LICENSE similarity index 100% rename from libs/Flask_Migrate-4.0.5.dist-info/LICENSE rename to libs/Flask_Migrate-4.0.7.dist-info/LICENSE diff --git a/libs/Flask_Migrate-4.0.5.dist-info/METADATA b/libs/Flask_Migrate-4.0.7.dist-info/METADATA similarity index 95% rename from libs/Flask_Migrate-4.0.5.dist-info/METADATA rename to libs/Flask_Migrate-4.0.7.dist-info/METADATA index 0590a4cf5..c0c67b93e 100644 --- a/libs/Flask_Migrate-4.0.5.dist-info/METADATA +++ b/libs/Flask_Migrate-4.0.7.dist-info/METADATA @@ -1,11 +1,10 @@ Metadata-Version: 2.1 Name: Flask-Migrate -Version: 4.0.5 +Version: 4.0.7 Summary: SQLAlchemy database migrations for Flask applications using Alembic. -Home-page: https://github.com/miguelgrinberg/flask-migrate -Author: Miguel Grinberg -Author-email: miguel.grinberg@gmail.com +Author-email: Miguel Grinberg License: MIT +Project-URL: Homepage, https://github.com/miguelgrinberg/flask-migrate Project-URL: Bug Tracker, https://github.com/miguelgrinberg/flask-migrate/issues Classifier: Environment :: Web Environment Classifier: Intended Audience :: Developers diff --git a/libs/Flask_Migrate-4.0.5.dist-info/RECORD b/libs/Flask_Migrate-4.0.7.dist-info/RECORD similarity index 73% rename from libs/Flask_Migrate-4.0.5.dist-info/RECORD rename to libs/Flask_Migrate-4.0.7.dist-info/RECORD index 740ada03e..c5f8dfe55 100644 --- a/libs/Flask_Migrate-4.0.5.dist-info/RECORD +++ b/libs/Flask_Migrate-4.0.7.dist-info/RECORD @@ -1,12 +1,12 @@ -Flask_Migrate-4.0.5.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4 -Flask_Migrate-4.0.5.dist-info/LICENSE,sha256=kfkXGlJQvKy3Y__6tAJ8ynIp1HQfeROXhL8jZU1d-DI,1082 -Flask_Migrate-4.0.5.dist-info/METADATA,sha256=d-EcnhZa_vyVAph2u84OpGIteJaBmqLQxO5Rf6wUI7Y,3095 -Flask_Migrate-4.0.5.dist-info/RECORD,, -Flask_Migrate-4.0.5.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 -Flask_Migrate-4.0.5.dist-info/WHEEL,sha256=oiQVh_5PnQM0E3gPdiz09WCNmwiHDMaGer_elqB3coM,92 -Flask_Migrate-4.0.5.dist-info/top_level.txt,sha256=jLoPgiMG6oR4ugNteXn3IHskVVIyIXVStZOVq-AWLdU,14 -flask_migrate/__init__.py,sha256=-JFdExGtr7UrwCpmjYvTfzFHqMjE7AmP0Rr3T53tBNU,10037 -flask_migrate/cli.py,sha256=H-N4NNS5HyEB61HpUADLU8pW3naejyDPgeEbzEqG5-w,10298 +Flask_Migrate-4.0.7.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4 +Flask_Migrate-4.0.7.dist-info/LICENSE,sha256=kfkXGlJQvKy3Y__6tAJ8ynIp1HQfeROXhL8jZU1d-DI,1082 +Flask_Migrate-4.0.7.dist-info/METADATA,sha256=3WW5StkAdKx66iP12BXfTzoUsSB4rqEGxVs3qoollRg,3101 +Flask_Migrate-4.0.7.dist-info/RECORD,, +Flask_Migrate-4.0.7.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +Flask_Migrate-4.0.7.dist-info/WHEEL,sha256=OVMc5UfuAQiSplgO0_WdW7vXVGAt9Hdd6qtN4HotdyA,91 +Flask_Migrate-4.0.7.dist-info/top_level.txt,sha256=jLoPgiMG6oR4ugNteXn3IHskVVIyIXVStZOVq-AWLdU,14 +flask_migrate/__init__.py,sha256=JMySGA55Y8Gxy3HviWu7qq5rPUNQBWc2NID2OicpDyw,10082 +flask_migrate/cli.py,sha256=v1fOqjpUI8ZniSt0NAxdaU4gFMoZys5yLAofwmBdMHU,10689 flask_migrate/templates/aioflask-multidb/README,sha256=Ek4cJqTaxneVjtkue--BXMlfpfp3MmJRjqoZvnSizww,43 flask_migrate/templates/aioflask-multidb/alembic.ini.mako,sha256=SjYEmJKzz6K8QfuZWtLJAJWcCKOdRbfUhsVlpgv8ock,857 flask_migrate/templates/aioflask-multidb/env.py,sha256=UcjeqkAbyUjTkuQFmCFPG7QOvqhco8-uGp8QEbto0T8,6573 diff --git a/libs/Flask_Migrate-4.0.5.dist-info/REQUESTED b/libs/Flask_Migrate-4.0.7.dist-info/REQUESTED similarity index 100% rename from libs/Flask_Migrate-4.0.5.dist-info/REQUESTED rename to libs/Flask_Migrate-4.0.7.dist-info/REQUESTED diff --git a/libs/Flask_Migrate-4.0.5.dist-info/WHEEL b/libs/Flask_Migrate-4.0.7.dist-info/WHEEL similarity index 65% rename from libs/Flask_Migrate-4.0.5.dist-info/WHEEL rename to libs/Flask_Migrate-4.0.7.dist-info/WHEEL index 98c0d20b7..da25d7b42 100644 --- a/libs/Flask_Migrate-4.0.5.dist-info/WHEEL +++ b/libs/Flask_Migrate-4.0.7.dist-info/WHEEL @@ -1,5 +1,5 @@ Wheel-Version: 1.0 -Generator: bdist_wheel (0.42.0) +Generator: setuptools (75.2.0) Root-Is-Purelib: true Tag: py3-none-any diff --git a/libs/Flask_Migrate-4.0.5.dist-info/top_level.txt b/libs/Flask_Migrate-4.0.7.dist-info/top_level.txt similarity index 100% rename from libs/Flask_Migrate-4.0.5.dist-info/top_level.txt rename to libs/Flask_Migrate-4.0.7.dist-info/top_level.txt diff --git a/libs/Flask_SocketIO-5.3.6.dist-info/RECORD b/libs/Flask_SocketIO-5.3.6.dist-info/RECORD deleted file mode 100644 index 4a6082118..000000000 --- a/libs/Flask_SocketIO-5.3.6.dist-info/RECORD +++ /dev/null @@ -1,10 +0,0 @@ -Flask_SocketIO-5.3.6.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4 -Flask_SocketIO-5.3.6.dist-info/LICENSE,sha256=aNCWbkgKjS_T1cJtACyZbvCM36KxWnfQ0LWTuavuYKQ,1082 -Flask_SocketIO-5.3.6.dist-info/METADATA,sha256=vmIOzjkNLXRjmocRXtso6hLV27aiJgH7_A55TVJyD4k,2631 -Flask_SocketIO-5.3.6.dist-info/RECORD,, -Flask_SocketIO-5.3.6.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 -Flask_SocketIO-5.3.6.dist-info/WHEEL,sha256=oiQVh_5PnQM0E3gPdiz09WCNmwiHDMaGer_elqB3coM,92 -Flask_SocketIO-5.3.6.dist-info/top_level.txt,sha256=C1ugzQBJ3HHUJsWGzyt70XRVOX-y4CUAR8MWKjwJOQ8,15 -flask_socketio/__init__.py,sha256=ea3QXRYKBje4JQGcNSEOmj42qlf2peRNbCzZZWfD9DE,54731 -flask_socketio/namespace.py,sha256=b3oyXEemu2po-wpoy4ILTHQMVuVQqicogCDxfymfz_w,2020 -flask_socketio/test_client.py,sha256=9_R1y_vP8yr8wzimQUEMAUyVqX12FMXurLj8t1ecDdc,11034 diff --git a/libs/Flask_SocketIO-5.3.6.dist-info/INSTALLER b/libs/Flask_SocketIO-5.5.0.dist-info/INSTALLER similarity index 100% rename from libs/Flask_SocketIO-5.3.6.dist-info/INSTALLER rename to libs/Flask_SocketIO-5.5.0.dist-info/INSTALLER diff --git a/libs/Flask_SocketIO-5.3.6.dist-info/LICENSE b/libs/Flask_SocketIO-5.5.0.dist-info/LICENSE similarity index 100% rename from libs/Flask_SocketIO-5.3.6.dist-info/LICENSE rename to libs/Flask_SocketIO-5.5.0.dist-info/LICENSE diff --git a/libs/Flask_SocketIO-5.3.6.dist-info/METADATA b/libs/Flask_SocketIO-5.5.0.dist-info/METADATA similarity index 94% rename from libs/Flask_SocketIO-5.3.6.dist-info/METADATA rename to libs/Flask_SocketIO-5.5.0.dist-info/METADATA index ddec6b09a..98bc5fd62 100644 --- a/libs/Flask_SocketIO-5.3.6.dist-info/METADATA +++ b/libs/Flask_SocketIO-5.5.0.dist-info/METADATA @@ -1,10 +1,9 @@ Metadata-Version: 2.1 Name: Flask-SocketIO -Version: 5.3.6 +Version: 5.5.0 Summary: Socket.IO integration for Flask applications -Home-page: https://github.com/miguelgrinberg/flask-socketio -Author: Miguel Grinberg -Author-email: miguel.grinberg@gmail.com +Author-email: Miguel Grinberg +Project-URL: Homepage, https://github.com/miguelgrinberg/flask-socketio Project-URL: Bug Tracker, https://github.com/miguelgrinberg/flask-socketio/issues Classifier: Environment :: Web Environment Classifier: Intended Audience :: Developers diff --git a/libs/Flask_SocketIO-5.5.0.dist-info/RECORD b/libs/Flask_SocketIO-5.5.0.dist-info/RECORD new file mode 100644 index 000000000..a86a348d1 --- /dev/null +++ b/libs/Flask_SocketIO-5.5.0.dist-info/RECORD @@ -0,0 +1,10 @@ +Flask_SocketIO-5.5.0.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4 +Flask_SocketIO-5.5.0.dist-info/LICENSE,sha256=aNCWbkgKjS_T1cJtACyZbvCM36KxWnfQ0LWTuavuYKQ,1082 +Flask_SocketIO-5.5.0.dist-info/METADATA,sha256=LRFQve-hu_8O4YwU7xPb2BDoRKp88hPIzyUmOogxHBg,2637 +Flask_SocketIO-5.5.0.dist-info/RECORD,, +Flask_SocketIO-5.5.0.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +Flask_SocketIO-5.5.0.dist-info/WHEEL,sha256=P9jw-gEje8ByB7_hXoICnHtVCrEwMQh-630tKvQWehc,91 +Flask_SocketIO-5.5.0.dist-info/top_level.txt,sha256=C1ugzQBJ3HHUJsWGzyt70XRVOX-y4CUAR8MWKjwJOQ8,15 +flask_socketio/__init__.py,sha256=5hN0LE0hfGMUDcX4FheZrtXERJ1IBEPagv0pgeqdtlU,54904 +flask_socketio/namespace.py,sha256=UkVryJvFYgnCMKWSF35GVfGdyh2cXRDyRbfmEPPchVA,2329 +flask_socketio/test_client.py,sha256=rClk02TSRqgidH8IyeohspKVKdpRx7gcZBjg1YUtZpA,11026 diff --git a/libs/Flask_SocketIO-5.3.6.dist-info/REQUESTED b/libs/Flask_SocketIO-5.5.0.dist-info/REQUESTED similarity index 100% rename from libs/Flask_SocketIO-5.3.6.dist-info/REQUESTED rename to libs/Flask_SocketIO-5.5.0.dist-info/REQUESTED diff --git a/libs/Flask_SocketIO-5.3.6.dist-info/WHEEL b/libs/Flask_SocketIO-5.5.0.dist-info/WHEEL similarity index 65% rename from libs/Flask_SocketIO-5.3.6.dist-info/WHEEL rename to libs/Flask_SocketIO-5.5.0.dist-info/WHEEL index 98c0d20b7..9b78c4451 100644 --- a/libs/Flask_SocketIO-5.3.6.dist-info/WHEEL +++ b/libs/Flask_SocketIO-5.5.0.dist-info/WHEEL @@ -1,5 +1,5 @@ Wheel-Version: 1.0 -Generator: bdist_wheel (0.42.0) +Generator: setuptools (75.3.0) Root-Is-Purelib: true Tag: py3-none-any diff --git a/libs/Flask_SocketIO-5.3.6.dist-info/top_level.txt b/libs/Flask_SocketIO-5.5.0.dist-info/top_level.txt similarity index 100% rename from libs/Flask_SocketIO-5.3.6.dist-info/top_level.txt rename to libs/Flask_SocketIO-5.5.0.dist-info/top_level.txt diff --git a/libs/Jinja2-3.1.3.dist-info/RECORD b/libs/Jinja2-3.1.3.dist-info/RECORD deleted file mode 100644 index e80904634..000000000 --- a/libs/Jinja2-3.1.3.dist-info/RECORD +++ /dev/null @@ -1,34 +0,0 @@ -Jinja2-3.1.3.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4 -Jinja2-3.1.3.dist-info/LICENSE.rst,sha256=O0nc7kEF6ze6wQ-vG-JgQI_oXSUrjp3y4JefweCUQ3s,1475 -Jinja2-3.1.3.dist-info/METADATA,sha256=0cLNbRCI91jytc7Bzv3XAQfZzFDF2gxkJuH46eF5vew,3301 -Jinja2-3.1.3.dist-info/RECORD,, -Jinja2-3.1.3.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 -Jinja2-3.1.3.dist-info/WHEEL,sha256=Xo9-1PvkuimrydujYJAjF7pCkriuXBpUPEjma1nZyJ0,92 -Jinja2-3.1.3.dist-info/entry_points.txt,sha256=zRd62fbqIyfUpsRtU7EVIFyiu1tPwfgO7EvPErnxgTE,59 -Jinja2-3.1.3.dist-info/top_level.txt,sha256=PkeVWtLb3-CqjWi1fO29OCbj55EhX_chhKrCdrVe_zs,7 -jinja2/__init__.py,sha256=NTBwMwsECrdHmxeXF7seusHLzrh6Ldn1A9qhS5cDuf0,1927 -jinja2/_identifier.py,sha256=_zYctNKzRqlk_murTNlzrju1FFJL7Va_Ijqqd7ii2lU,1958 -jinja2/async_utils.py,sha256=dFcmh6lMNfbh7eLKrBio8JqAKLHdZbpCuurFN4OERtY,2447 -jinja2/bccache.py,sha256=mhz5xtLxCcHRAa56azOhphIAe19u1we0ojifNMClDio,14061 -jinja2/compiler.py,sha256=PJzYdRLStlEOqmnQs1YxlizPrJoj3jTZuUleREn6AIQ,72199 -jinja2/constants.py,sha256=GMoFydBF_kdpaRKPoM5cl5MviquVRLVyZtfp5-16jg0,1433 -jinja2/debug.py,sha256=iWJ432RadxJNnaMOPrjIDInz50UEgni3_HKuFXi2vuQ,6299 -jinja2/defaults.py,sha256=boBcSw78h-lp20YbaXSJsqkAI2uN_mD_TtCydpeq5wU,1267 -jinja2/environment.py,sha256=0qldX3VQKZcm6lgn7zHz94oRFow7YPYERiqkquomNjU,61253 -jinja2/exceptions.py,sha256=ioHeHrWwCWNaXX1inHmHVblvc4haO7AXsjCp3GfWvx0,5071 -jinja2/ext.py,sha256=5fnMpllaXkfm2P_93RIvi-OnK7Tk8mCW8Du-GcD12Hc,31844 -jinja2/filters.py,sha256=vYjKb2zaPShvYtn_LpSmqfS8SScbrA_KOanNibsMDIE,53862 -jinja2/idtracking.py,sha256=GfNmadir4oDALVxzn3DL9YInhJDr69ebXeA2ygfuCGA,10704 -jinja2/lexer.py,sha256=DW2nX9zk-6MWp65YR2bqqj0xqCvLtD-u9NWT8AnFRxQ,29726 -jinja2/loaders.py,sha256=ayAwxfrA1SAffQta0nwSDm3TDT4KYiIGN_D9Z45B310,23085 -jinja2/meta.py,sha256=GNPEvifmSaU3CMxlbheBOZjeZ277HThOPUTf1RkppKQ,4396 -jinja2/nativetypes.py,sha256=7GIGALVJgdyL80oZJdQUaUfwSt5q2lSSZbXt0dNf_M4,4210 -jinja2/nodes.py,sha256=i34GPRAZexXMT6bwuf5SEyvdmS-bRCy9KMjwN5O6pjk,34550 -jinja2/optimizer.py,sha256=tHkMwXxfZkbfA1KmLcqmBMSaz7RLIvvItrJcPoXTyD8,1650 -jinja2/parser.py,sha256=Y199wPL-G67gJoi5G_5sHuu9uEP1PJkjjLEW_xTH8-k,39736 -jinja2/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 -jinja2/runtime.py,sha256=_6LkKIWFJjQdqlrgA3K39zBFQ-7Orm3wGDm96RwxQoE,33406 -jinja2/sandbox.py,sha256=Y0xZeXQnH6EX5VjaV2YixESxoepnRbW_3UeQosaBU3M,14584 -jinja2/tests.py,sha256=Am5Z6Lmfr2XaH_npIfJJ8MdXtWsbLjMULZJulTAj30E,5905 -jinja2/utils.py,sha256=IMwRIcN1SsTw2-jdQtlH2KzNABsXZBW_-tnFXafQBvY,23933 -jinja2/visitor.py,sha256=MH14C6yq24G_KVtWzjwaI7Wg14PCJIYlWW1kpkxYak0,3568 diff --git a/libs/Jinja2-3.1.3.dist-info/entry_points.txt b/libs/Jinja2-3.1.3.dist-info/entry_points.txt deleted file mode 100644 index 7b9666c8e..000000000 --- a/libs/Jinja2-3.1.3.dist-info/entry_points.txt +++ /dev/null @@ -1,2 +0,0 @@ -[babel.extractors] -jinja2 = jinja2.ext:babel_extract[i18n] diff --git a/libs/Jinja2-3.1.3.dist-info/top_level.txt b/libs/Jinja2-3.1.3.dist-info/top_level.txt deleted file mode 100644 index 7f7afbf3b..000000000 --- a/libs/Jinja2-3.1.3.dist-info/top_level.txt +++ /dev/null @@ -1 +0,0 @@ -jinja2 diff --git a/libs/Jinja2-3.1.3.dist-info/INSTALLER b/libs/Mako-1.3.8.dist-info/INSTALLER similarity index 100% rename from libs/Jinja2-3.1.3.dist-info/INSTALLER rename to libs/Mako-1.3.8.dist-info/INSTALLER diff --git a/libs/Mako-1.3.2.dist-info/LICENSE b/libs/Mako-1.3.8.dist-info/LICENSE similarity index 100% rename from libs/Mako-1.3.2.dist-info/LICENSE rename to libs/Mako-1.3.8.dist-info/LICENSE diff --git a/libs/Mako-1.3.2.dist-info/METADATA b/libs/Mako-1.3.8.dist-info/METADATA similarity index 94% rename from libs/Mako-1.3.2.dist-info/METADATA rename to libs/Mako-1.3.8.dist-info/METADATA index 4558ed984..bf0b1f45b 100644 --- a/libs/Mako-1.3.2.dist-info/METADATA +++ b/libs/Mako-1.3.8.dist-info/METADATA @@ -1,6 +1,6 @@ Metadata-Version: 2.1 Name: Mako -Version: 1.3.2 +Version: 1.3.8 Summary: A super-fast templating language that borrows the best ideas from the existing templating languages. Home-page: https://www.makotemplates.org/ Author: Mike Bayer @@ -25,13 +25,13 @@ Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content Requires-Python: >=3.8 Description-Content-Type: text/x-rst License-File: LICENSE -Requires-Dist: MarkupSafe >=0.9.2 +Requires-Dist: MarkupSafe>=0.9.2 Provides-Extra: babel -Requires-Dist: Babel ; extra == 'babel' +Requires-Dist: Babel; extra == "babel" Provides-Extra: lingua -Requires-Dist: lingua ; extra == 'lingua' +Requires-Dist: lingua; extra == "lingua" Provides-Extra: testing -Requires-Dist: pytest ; extra == 'testing' +Requires-Dist: pytest; extra == "testing" ========================= Mako Templates for Python diff --git a/libs/Mako-1.3.2.dist-info/RECORD b/libs/Mako-1.3.8.dist-info/RECORD similarity index 72% rename from libs/Mako-1.3.2.dist-info/RECORD rename to libs/Mako-1.3.8.dist-info/RECORD index fed8f2c56..7e79ae7aa 100644 --- a/libs/Mako-1.3.2.dist-info/RECORD +++ b/libs/Mako-1.3.8.dist-info/RECORD @@ -1,18 +1,18 @@ ../../bin/mako-render,sha256=NK39DgCmw8pz5T7ALDcW2MB6hFGNVOpWXAHq3-GKyss,236 -Mako-1.3.2.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4 -Mako-1.3.2.dist-info/LICENSE,sha256=FWJ7NrONBynN1obfmr9gZQPZnWJLL17FyyVKddWvqJE,1098 -Mako-1.3.2.dist-info/METADATA,sha256=G3lsPTYAPanaYdk-_e8yek5DZpuOjT3Qcf-RMLrlMU0,2900 -Mako-1.3.2.dist-info/RECORD,, -Mako-1.3.2.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 -Mako-1.3.2.dist-info/WHEEL,sha256=oiQVh_5PnQM0E3gPdiz09WCNmwiHDMaGer_elqB3coM,92 -Mako-1.3.2.dist-info/entry_points.txt,sha256=LsKkUsOsJQYbJ2M72hZCm968wi5K8Ywb5uFxCuN8Obk,512 -Mako-1.3.2.dist-info/top_level.txt,sha256=LItdH8cDPetpUu8rUyBG3DObS6h9Gcpr9j_WLj2S-R0,5 -mako/__init__.py,sha256=brA7o1ju8zST1YWfFRXDxaKmMlKiRHVqefjxeUN6YjI,242 +Mako-1.3.8.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4 +Mako-1.3.8.dist-info/LICENSE,sha256=FWJ7NrONBynN1obfmr9gZQPZnWJLL17FyyVKddWvqJE,1098 +Mako-1.3.8.dist-info/METADATA,sha256=YtMX8Z6wVX7TvuBzOsUAOAq_jdceHFW4rR6hwvMNZgE,2896 +Mako-1.3.8.dist-info/RECORD,, +Mako-1.3.8.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +Mako-1.3.8.dist-info/WHEEL,sha256=P9jw-gEje8ByB7_hXoICnHtVCrEwMQh-630tKvQWehc,91 +Mako-1.3.8.dist-info/entry_points.txt,sha256=LsKkUsOsJQYbJ2M72hZCm968wi5K8Ywb5uFxCuN8Obk,512 +Mako-1.3.8.dist-info/top_level.txt,sha256=LItdH8cDPetpUu8rUyBG3DObS6h9Gcpr9j_WLj2S-R0,5 +mako/__init__.py,sha256=sMLX8sANJQjjeIsZjbrwotWPXHEpRcKxELPgkx2Cyw8,242 mako/_ast_util.py,sha256=CenxCrdES1irHDhOQU6Ldta4rdsytfYaMkN6s0TlveM,20247 mako/ast.py,sha256=pY7MH-5cLnUuVz5YAwoGhWgWfgoVvLQkRDtc_s9qqw0,6642 mako/cache.py,sha256=5DBBorj1NqiWDqNhN3ZJ8tMCm-h6Mew541276kdsxAU,7680 mako/cmd.py,sha256=vP5M5g9yc5sjAT5owVTQu056YwyS-YkpulFSDb0IMGw,2813 -mako/codegen.py,sha256=UgB8K6BMNiBTUGucR4UYkqFqlbNUJfErKI9A-n4Wteg,47307 +mako/codegen.py,sha256=XRhzcuGEleDUXTfmOjw4alb6TkczbmEfBCLqID8x4bA,47736 mako/compat.py,sha256=wjVMf7uMg0TlC_aI5hdwWizza99nqJuGNdrnTNrZbt0,1820 mako/exceptions.py,sha256=pfdd5-1lCZ--I2YqQ_oHODZLmo62bn_lO5Kz_1__72w,12530 mako/ext/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 @@ -25,11 +25,11 @@ mako/ext/preprocessors.py,sha256=zKQy42Ce6dOmU0Yk_rUVDAAn38-RUUfQolVKTJjLotA,576 mako/ext/pygmentplugin.py,sha256=qBdsAhKktlQX7d5Yv1sAXufUNOZqcnJmKuC7V4D_srM,4753 mako/ext/turbogears.py,sha256=0emY1WiMnuY8Pf6ARv5JBArKtouUdmuTljI-w6rE3J4,2141 mako/filters.py,sha256=F7aDIKTUxnT-Og4rgboQtnML7Q87DJTHQyhi_dY_Ih4,4658 -mako/lexer.py,sha256=dtCZU1eoF3ymEdiCwCzEIw5SH0lgJkDsHJy9VHI_2XY,16324 +mako/lexer.py,sha256=Xi6Lk8CnASf3UYAaPoYrfjuPkrYauNjvYvULCUkKYaY,16321 mako/lookup.py,sha256=rkMvT5T7EOS5KRvPtgYii-sjh1nWWyKok_mEk-cEzrM,12428 -mako/parsetree.py,sha256=7RNVRTsKcsMt8vU4NQi5C7e4vhdUyA9tqyd1yIkvAAQ,19007 +mako/parsetree.py,sha256=BHdZI9vyxKB27Q4hzym5TdZ_982_3k31_HMsGLz3Tlg,19021 mako/pygen.py,sha256=d4f_ugRACCXuV9hJgEk6Ncoj38EaRHA3RTxkr_tK7UQ,10416 -mako/pyparser.py,sha256=81rIcSn4PoALpZF0WO6D5rB65TvF8R9Qn_hBSfTGS5Q,7029 +mako/pyparser.py,sha256=eY_a94QDXaK3vIA2jZYT9so7oXKKJLT0SO_Yrl3IOb8,7478 mako/runtime.py,sha256=ZsUEN22nX3d3dECQujF69mBKDQS6yVv2nvz_0eTvFGg,27804 mako/template.py,sha256=4xQzwruZd5XzPw7iONZMZJj4SdFsctYYg4PfBYs2PLk,23857 mako/testing/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 diff --git a/libs/Jinja2-3.1.3.dist-info/REQUESTED b/libs/Mako-1.3.8.dist-info/REQUESTED similarity index 100% rename from libs/Jinja2-3.1.3.dist-info/REQUESTED rename to libs/Mako-1.3.8.dist-info/REQUESTED diff --git a/libs/Mako-1.3.2.dist-info/WHEEL b/libs/Mako-1.3.8.dist-info/WHEEL similarity index 65% rename from libs/Mako-1.3.2.dist-info/WHEEL rename to libs/Mako-1.3.8.dist-info/WHEEL index 98c0d20b7..9b78c4451 100644 --- a/libs/Mako-1.3.2.dist-info/WHEEL +++ b/libs/Mako-1.3.8.dist-info/WHEEL @@ -1,5 +1,5 @@ Wheel-Version: 1.0 -Generator: bdist_wheel (0.42.0) +Generator: setuptools (75.3.0) Root-Is-Purelib: true Tag: py3-none-any diff --git a/libs/Mako-1.3.2.dist-info/entry_points.txt b/libs/Mako-1.3.8.dist-info/entry_points.txt similarity index 100% rename from libs/Mako-1.3.2.dist-info/entry_points.txt rename to libs/Mako-1.3.8.dist-info/entry_points.txt diff --git a/libs/Mako-1.3.2.dist-info/top_level.txt b/libs/Mako-1.3.8.dist-info/top_level.txt similarity index 100% rename from libs/Mako-1.3.2.dist-info/top_level.txt rename to libs/Mako-1.3.8.dist-info/top_level.txt diff --git a/libs/Markdown-3.5.2.dist-info/LICENSE.md b/libs/Markdown-3.5.2.dist-info/LICENSE.md deleted file mode 100644 index 2652d97ad..000000000 --- a/libs/Markdown-3.5.2.dist-info/LICENSE.md +++ /dev/null @@ -1,29 +0,0 @@ -Copyright 2007, 2008 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) - -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 Python Markdown 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 PYTHON MARKDOWN PROJECT ''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 ANY CONTRIBUTORS TO THE PYTHON MARKDOWN PROJECT -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/libs/Mako-1.3.2.dist-info/INSTALLER b/libs/Markdown-3.7.dist-info/INSTALLER similarity index 100% rename from libs/Mako-1.3.2.dist-info/INSTALLER rename to libs/Markdown-3.7.dist-info/INSTALLER diff --git a/libs/Markdown-3.7.dist-info/LICENSE.md b/libs/Markdown-3.7.dist-info/LICENSE.md new file mode 100644 index 000000000..6249d60ce --- /dev/null +++ b/libs/Markdown-3.7.dist-info/LICENSE.md @@ -0,0 +1,30 @@ +BSD 3-Clause License + +Copyright 2007, 2008 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) + +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 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/libs/Markdown-3.5.2.dist-info/METADATA b/libs/Markdown-3.7.dist-info/METADATA similarity index 76% rename from libs/Markdown-3.5.2.dist-info/METADATA rename to libs/Markdown-3.7.dist-info/METADATA index 866453f0b..233bc55ba 100644 --- a/libs/Markdown-3.5.2.dist-info/METADATA +++ b/libs/Markdown-3.7.dist-info/METADATA @@ -1,40 +1,41 @@ Metadata-Version: 2.1 Name: Markdown -Version: 3.5.2 +Version: 3.7 Summary: Python implementation of John Gruber's Markdown. Author: Manfred Stienstra, Yuri Takhteyev Author-email: Waylan limberg Maintainer: Isaac Muse Maintainer-email: Waylan Limberg -License: Copyright 2007, 2008 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 3-Clause License - All rights reserved. + Copyright 2007, 2008 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) 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 Python Markdown Project nor the - names of its contributors may be used to endorse or promote products - derived from this software without specific prior written permission. + 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 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 PYTHON MARKDOWN PROJECT ''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 ANY CONTRIBUTORS TO THE PYTHON MARKDOWN PROJECT - 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. + 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. Project-URL: Homepage, https://Python-Markdown.github.io/ Project-URL: Documentation, https://Python-Markdown.github.io/ diff --git a/libs/Markdown-3.5.2.dist-info/RECORD b/libs/Markdown-3.7.dist-info/RECORD similarity index 64% rename from libs/Markdown-3.5.2.dist-info/RECORD rename to libs/Markdown-3.7.dist-info/RECORD index a3e6a9479..34d25a0c7 100644 --- a/libs/Markdown-3.5.2.dist-info/RECORD +++ b/libs/Markdown-3.7.dist-info/RECORD @@ -1,26 +1,26 @@ ../../bin/markdown_py,sha256=a0a3HrUHepb4z4hcrRdCfAEQ8SiB-QoWxf9g1e-KLv8,237 -Markdown-3.5.2.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4 -Markdown-3.5.2.dist-info/LICENSE.md,sha256=bxGTy2NHGOZcOlN9biXr1hSCDsDvaTz8EiSBEmONZNo,1645 -Markdown-3.5.2.dist-info/METADATA,sha256=9pbPWhPBzgBE-uAwHn0vC0CGT-mw0KC8-SanIkqAFUo,7029 -Markdown-3.5.2.dist-info/RECORD,, -Markdown-3.5.2.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 -Markdown-3.5.2.dist-info/WHEEL,sha256=oiQVh_5PnQM0E3gPdiz09WCNmwiHDMaGer_elqB3coM,92 -Markdown-3.5.2.dist-info/entry_points.txt,sha256=lMEyiiA_ZZyfPCBlDviBl-SiU0cfoeuEKpwxw361sKQ,1102 -Markdown-3.5.2.dist-info/top_level.txt,sha256=IAxs8x618RXoH1uCqeLLxXsDefJvE_mIibr_M4sOlyk,9 +Markdown-3.7.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4 +Markdown-3.7.dist-info/LICENSE.md,sha256=e6TrbRCzKy0R3OE4ITQDUc27swuozMZ4Qdsv_Ybnmso,1650 +Markdown-3.7.dist-info/METADATA,sha256=nY8sewcY6R1akyROqkyO-Jk_eUDY8am_C4MkRP79sWA,7040 +Markdown-3.7.dist-info/RECORD,, +Markdown-3.7.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +Markdown-3.7.dist-info/WHEEL,sha256=OVMc5UfuAQiSplgO0_WdW7vXVGAt9Hdd6qtN4HotdyA,91 +Markdown-3.7.dist-info/entry_points.txt,sha256=lMEyiiA_ZZyfPCBlDviBl-SiU0cfoeuEKpwxw361sKQ,1102 +Markdown-3.7.dist-info/top_level.txt,sha256=IAxs8x618RXoH1uCqeLLxXsDefJvE_mIibr_M4sOlyk,9 markdown/__init__.py,sha256=dfzwwdpG9L8QLEPBpLFPIHx_BN056aZXp9xZifTxYIU,1777 markdown/__main__.py,sha256=innFBxRqwPBNxG1zhKktJji4bnRKtVyYYd30ID13Tcw,5859 -markdown/__meta__.py,sha256=K-Yr6cieMQWMYt9f7Rx8PGkX2PYqz4aVJdupZf_CE2Q,1712 +markdown/__meta__.py,sha256=RhwfJ30zyGvJaJXLHwQdNH5jw69-5fVKu2p-CVaJz0U,1712 markdown/blockparser.py,sha256=j4CQImVpiq7g9pz8wCxvzT61X_T2iSAjXupHJk8P3eA,5728 -markdown/blockprocessors.py,sha256=dZFoOXABuOs5AFNpWrrsVEmPe1i8_JVe4rOusdRRPAA,26648 +markdown/blockprocessors.py,sha256=koY5rq8DixzBCHcquvZJp6x2JYyBGjrwxMWNZhd6D2U,27013 markdown/core.py,sha256=DyyzDsmd-KcuEp8ZWUKJAeUCt7B7G3J3NeqZqp3LphI,21335 markdown/extensions/__init__.py,sha256=9z1khsdKCVrmrJ_2GfxtPAdjD3FyMe5vhC7wmM4O9m0,4822 -markdown/extensions/abbr.py,sha256=J27cKf_vKY5wdKA_Bunwk83c3RpxwfgDeGarFF0FCuk,3540 +markdown/extensions/abbr.py,sha256=Gqt9TUtLWez2cbsy3SQk5152RZekops2fUJj01bfkfw,6903 markdown/extensions/admonition.py,sha256=Hqcn3I8JG0i-OPWdoqI189TmlQRgH6bs5PmpCANyLlg,6547 -markdown/extensions/attr_list.py,sha256=6PzqkH7N_U5lks7PGH7dSGmEGVuMCYR9MaR3g8c9Spw,6584 +markdown/extensions/attr_list.py,sha256=t3PrgAr5Ebldnq3nJNbteBt79bN0ccXS5RemmQfUZ9g,7820 markdown/extensions/codehilite.py,sha256=ChlmpM6S--j-UK7t82859UpYjm8EftdiLqmgDnknyes,13503 markdown/extensions/def_list.py,sha256=J3NVa6CllfZPsboJCEycPyRhtjBHnOn8ET6omEvVlDo,4029 markdown/extensions/extra.py,sha256=1vleT284kued4HQBtF83IjSumJVo0q3ng6MjTkVNfNQ,2163 -markdown/extensions/fenced_code.py,sha256=Xy4sQDjEsSJuShiAf9bwpv8Khtyf7Y6QDjNlH7QVM-Q,7817 +markdown/extensions/fenced_code.py,sha256=-fYSmRZ9DTYQ8HO9b_78i47kVyVu6mcVJlqVTMdzvo4,8300 markdown/extensions/footnotes.py,sha256=bRFlmIBOKDI5efG1jZfDkMoV2osfqWip1rN1j2P-mMg,16710 markdown/extensions/legacy_attrs.py,sha256=oWcyNrfP0F6zsBoBOaD5NiwrJyy4kCpgQLl12HA7JGU,2788 markdown/extensions/legacy_em.py,sha256=-Z_w4PEGSS-Xg-2-BtGAnXwwy5g5GDgv2tngASnPgxg,1693 @@ -28,9 +28,9 @@ markdown/extensions/md_in_html.py,sha256=y4HEWEnkvfih22fojcaJeAmjx1AtF8N-a_jb6ID markdown/extensions/meta.py,sha256=v_4Uq7nbcQ76V1YAvqVPiNLbRLIQHJsnfsk-tN70RmY,2600 markdown/extensions/nl2br.py,sha256=9KKcrPs62c3ENNnmOJZs0rrXXqUtTCfd43j1_OPpmgU,1090 markdown/extensions/sane_lists.py,sha256=ogAKcm7gEpcXV7fSTf8JZH5YdKAssPCEOUzdGM3C9Tw,2150 -markdown/extensions/smarty.py,sha256=DLmH22prpdZLDkV7GOCC1OTlCbTknKPHT9UNPs5-TwQ,11048 +markdown/extensions/smarty.py,sha256=yqT0OiE2AqYrqqZtcUFFmp2eJsQHomiKzgyG2JFb9rI,11048 markdown/extensions/tables.py,sha256=oTDvGD1qp9xjVWPGYNgDBWe9NqsX5gS6UU5wUsQ1bC8,8741 -markdown/extensions/toc.py,sha256=Vo2PFW4I0-ixOxTXzhoMhUTIAGiDeLPT7l0LNc9ZcBI,15293 +markdown/extensions/toc.py,sha256=PGg-EqbBubm3n0b633r8Xa9kc6JIdbo20HGAOZ6GEl8,18322 markdown/extensions/wikilinks.py,sha256=j7D2sozica6sqXOUa_GuAXqIzxp-7Hi60bfXymiuma8,3285 markdown/htmlparser.py,sha256=dEr6IE7i9b6Tc1gdCLZGeWw6g6-E-jK1Z4KPj8yGk8Q,14332 markdown/inlinepatterns.py,sha256=7_HF5nTOyQag_CyBgU4wwmuI6aMjtadvGadyS9IP21w,38256 diff --git a/libs/Mako-1.3.2.dist-info/REQUESTED b/libs/Markdown-3.7.dist-info/REQUESTED similarity index 100% rename from libs/Mako-1.3.2.dist-info/REQUESTED rename to libs/Markdown-3.7.dist-info/REQUESTED diff --git a/libs/Markdown-3.5.2.dist-info/WHEEL b/libs/Markdown-3.7.dist-info/WHEEL similarity index 65% rename from libs/Markdown-3.5.2.dist-info/WHEEL rename to libs/Markdown-3.7.dist-info/WHEEL index 98c0d20b7..da25d7b42 100644 --- a/libs/Markdown-3.5.2.dist-info/WHEEL +++ b/libs/Markdown-3.7.dist-info/WHEEL @@ -1,5 +1,5 @@ Wheel-Version: 1.0 -Generator: bdist_wheel (0.42.0) +Generator: setuptools (75.2.0) Root-Is-Purelib: true Tag: py3-none-any diff --git a/libs/Markdown-3.5.2.dist-info/entry_points.txt b/libs/Markdown-3.7.dist-info/entry_points.txt similarity index 100% rename from libs/Markdown-3.5.2.dist-info/entry_points.txt rename to libs/Markdown-3.7.dist-info/entry_points.txt diff --git a/libs/Markdown-3.5.2.dist-info/top_level.txt b/libs/Markdown-3.7.dist-info/top_level.txt similarity index 100% rename from libs/Markdown-3.5.2.dist-info/top_level.txt rename to libs/Markdown-3.7.dist-info/top_level.txt diff --git a/libs/MarkupSafe-2.1.5.dist-info/RECORD b/libs/MarkupSafe-2.1.5.dist-info/RECORD index 2b3c4338b..57cd62847 100644 --- a/libs/MarkupSafe-2.1.5.dist-info/RECORD +++ b/libs/MarkupSafe-2.1.5.dist-info/RECORD @@ -8,6 +8,6 @@ MarkupSafe-2.1.5.dist-info/top_level.txt,sha256=qy0Plje5IJuvsCBjejJyhDCjEAdcDLK_ markupsafe/__init__.py,sha256=r7VOTjUq7EMQ4v3p4R1LoVOGJg6ysfYRncLr34laRBs,10958 markupsafe/_native.py,sha256=GR86Qvo_GcgKmKreA1WmYN9ud17OFwkww8E-fiW-57s,1713 markupsafe/_speedups.c,sha256=X2XvQVtIdcK4Usz70BvkzoOfjTCmQlDkkjYSn-swE0g,7083 -markupsafe/_speedups.cpython-38-darwin.so,sha256=_9uBZXDBin5PdKhWn3XnAUpjp031_nC6MFOMuSDD-3g,51480 +markupsafe/_speedups.cpython-38-darwin.so,sha256=1yfD14PZ-QrFSi3XHMHazowfHExBdp5WS7IC86gAuRc,18712 markupsafe/_speedups.pyi,sha256=vfMCsOgbAXRNLUXkyuyonG8uEWKYU4PDqNuMaDELAYw,229 markupsafe/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 diff --git a/libs/Markdown-3.5.2.dist-info/INSTALLER b/libs/PyYAML-6.0.2.dist-info/INSTALLER similarity index 100% rename from libs/Markdown-3.5.2.dist-info/INSTALLER rename to libs/PyYAML-6.0.2.dist-info/INSTALLER diff --git a/libs/PyYAML-6.0.1.dist-info/LICENSE b/libs/PyYAML-6.0.2.dist-info/LICENSE similarity index 100% rename from libs/PyYAML-6.0.1.dist-info/LICENSE rename to libs/PyYAML-6.0.2.dist-info/LICENSE diff --git a/libs/PyYAML-6.0.1.dist-info/METADATA b/libs/PyYAML-6.0.2.dist-info/METADATA similarity index 93% rename from libs/PyYAML-6.0.1.dist-info/METADATA rename to libs/PyYAML-6.0.2.dist-info/METADATA index c8905983e..db029b770 100644 --- a/libs/PyYAML-6.0.1.dist-info/METADATA +++ b/libs/PyYAML-6.0.2.dist-info/METADATA @@ -1,6 +1,6 @@ Metadata-Version: 2.1 Name: PyYAML -Version: 6.0.1 +Version: 6.0.2 Summary: YAML parser and emitter for Python Home-page: https://pyyaml.org/ Download-URL: https://pypi.org/project/PyYAML/ @@ -20,17 +20,17 @@ Classifier: Operating System :: OS Independent Classifier: Programming Language :: Cython Classifier: Programming Language :: Python Classifier: Programming Language :: Python :: 3 -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.12 +Classifier: Programming Language :: Python :: 3.13 Classifier: Programming Language :: Python :: Implementation :: CPython Classifier: Programming Language :: Python :: Implementation :: PyPy Classifier: Topic :: Software Development :: Libraries :: Python Modules Classifier: Topic :: Text Processing :: Markup -Requires-Python: >=3.6 +Requires-Python: >=3.8 License-File: LICENSE YAML is a data serialization format designed for human readability diff --git a/libs/PyYAML-6.0.1.dist-info/RECORD b/libs/PyYAML-6.0.2.dist-info/RECORD similarity index 71% rename from libs/PyYAML-6.0.1.dist-info/RECORD rename to libs/PyYAML-6.0.2.dist-info/RECORD index 079577cf1..f01fe7622 100644 --- a/libs/PyYAML-6.0.1.dist-info/RECORD +++ b/libs/PyYAML-6.0.2.dist-info/RECORD @@ -1,12 +1,12 @@ -PyYAML-6.0.1.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4 -PyYAML-6.0.1.dist-info/LICENSE,sha256=jTko-dxEkP1jVwfLiOsmvXZBAqcoKVQwfT5RZ6V36KQ,1101 -PyYAML-6.0.1.dist-info/METADATA,sha256=UNNF8-SzzwOKXVo-kV5lXUGH2_wDWMBmGxqISpp5HQk,2058 -PyYAML-6.0.1.dist-info/RECORD,, -PyYAML-6.0.1.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 -PyYAML-6.0.1.dist-info/WHEEL,sha256=UkJU7GAEhyIKVwGX1j1L5OGjUFT--MirwyCseqfCpZU,109 -PyYAML-6.0.1.dist-info/top_level.txt,sha256=rpj0IVMTisAjh_1vG3Ccf9v5jpCQwAz6cD1IVU5ZdhQ,11 +PyYAML-6.0.2.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4 +PyYAML-6.0.2.dist-info/LICENSE,sha256=jTko-dxEkP1jVwfLiOsmvXZBAqcoKVQwfT5RZ6V36KQ,1101 +PyYAML-6.0.2.dist-info/METADATA,sha256=9-odFB5seu4pGPcEv7E8iyxNF51_uKnaNGjLAhz2lto,2060 +PyYAML-6.0.2.dist-info/RECORD,, +PyYAML-6.0.2.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +PyYAML-6.0.2.dist-info/WHEEL,sha256=39uaw0gKzAUihvDPhgMAk_aKXc5F8smdVlzAUVAVruU,109 +PyYAML-6.0.2.dist-info/top_level.txt,sha256=rpj0IVMTisAjh_1vG3Ccf9v5jpCQwAz6cD1IVU5ZdhQ,11 _yaml/__init__.py,sha256=04Ae_5osxahpJHa3XBZUAf4wi6XX32gR8D6X6p64GEA,1402 -yaml/__init__.py,sha256=bhl05qSeO-1ZxlSRjGrvl2m9nrXb1n9-GQatTN0Mrqc,12311 +yaml/__init__.py,sha256=N35S01HMesFTe0aRRMWkPj0Pa8IEbHpE9FK7cr5Bdtw,12311 yaml/composer.py,sha256=_Ko30Wr6eDWUeUpauUGT3Lcg9QPBnOPVlTnIMRGJ9FM,4883 yaml/constructor.py,sha256=kNgkfaeLUkwQYY_Q6Ff1Tz2XVw_pG1xVE9Ak7z-viLA,28639 yaml/cyaml.py,sha256=6ZrAG9fAYvdVe2FK_w0hmXoG7ZYsoYUwapG8CiC72H0,3851 diff --git a/libs/Markdown-3.5.2.dist-info/REQUESTED b/libs/PyYAML-6.0.2.dist-info/REQUESTED similarity index 100% rename from libs/Markdown-3.5.2.dist-info/REQUESTED rename to libs/PyYAML-6.0.2.dist-info/REQUESTED diff --git a/libs/PyYAML-6.0.1.dist-info/WHEEL b/libs/PyYAML-6.0.2.dist-info/WHEEL similarity index 70% rename from libs/PyYAML-6.0.1.dist-info/WHEEL rename to libs/PyYAML-6.0.2.dist-info/WHEEL index 844cf17ca..b8b9cfd4c 100644 --- a/libs/PyYAML-6.0.1.dist-info/WHEEL +++ b/libs/PyYAML-6.0.2.dist-info/WHEEL @@ -1,5 +1,5 @@ Wheel-Version: 1.0 -Generator: bdist_wheel (0.42.0) +Generator: bdist_wheel (0.44.0) Root-Is-Purelib: false Tag: cp38-cp38-macosx_12_0_x86_64 diff --git a/libs/PyYAML-6.0.1.dist-info/top_level.txt b/libs/PyYAML-6.0.2.dist-info/top_level.txt similarity index 100% rename from libs/PyYAML-6.0.1.dist-info/top_level.txt rename to libs/PyYAML-6.0.2.dist-info/top_level.txt diff --git a/libs/PyYAML-6.0.1.dist-info/INSTALLER b/libs/SQLAlchemy-2.0.36.dist-info/INSTALLER similarity index 100% rename from libs/PyYAML-6.0.1.dist-info/INSTALLER rename to libs/SQLAlchemy-2.0.36.dist-info/INSTALLER diff --git a/libs/SQLAlchemy-2.0.27.dist-info/LICENSE b/libs/SQLAlchemy-2.0.36.dist-info/LICENSE similarity index 100% rename from libs/SQLAlchemy-2.0.27.dist-info/LICENSE rename to libs/SQLAlchemy-2.0.36.dist-info/LICENSE diff --git a/libs/SQLAlchemy-2.0.27.dist-info/METADATA b/libs/SQLAlchemy-2.0.36.dist-info/METADATA similarity index 95% rename from libs/SQLAlchemy-2.0.27.dist-info/METADATA rename to libs/SQLAlchemy-2.0.36.dist-info/METADATA index e43a4599d..0c802c4dc 100644 --- a/libs/SQLAlchemy-2.0.27.dist-info/METADATA +++ b/libs/SQLAlchemy-2.0.36.dist-info/METADATA @@ -1,6 +1,6 @@ Metadata-Version: 2.1 Name: SQLAlchemy -Version: 2.0.27 +Version: 2.0.36 Summary: Database Abstraction Library Home-page: https://www.sqlalchemy.org Author: Mike Bayer @@ -20,6 +20,7 @@ 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.13 Classifier: Programming Language :: Python :: Implementation :: CPython Classifier: Programming Language :: Python :: Implementation :: PyPy Classifier: Topic :: Database :: Front-Ends @@ -27,7 +28,7 @@ Requires-Python: >=3.7 Description-Content-Type: text/x-rst License-File: LICENSE Requires-Dist: typing-extensions >=4.6.0 -Requires-Dist: greenlet !=0.4.17 ; platform_machine == "aarch64" or (platform_machine == "ppc64le" or (platform_machine == "x86_64" or (platform_machine == "amd64" or (platform_machine == "AMD64" or (platform_machine == "win32" or platform_machine == "WIN32"))))) +Requires-Dist: greenlet !=0.4.17 ; python_version < "3.13" and (platform_machine == "aarch64" or (platform_machine == "ppc64le" or (platform_machine == "x86_64" or (platform_machine == "amd64" or (platform_machine == "AMD64" or (platform_machine == "win32" or platform_machine == "WIN32")))))) Requires-Dist: importlib-metadata ; python_version < "3.8" Provides-Extra: aiomysql Requires-Dist: greenlet !=0.4.17 ; extra == 'aiomysql' @@ -45,7 +46,7 @@ Provides-Extra: asyncmy Requires-Dist: greenlet !=0.4.17 ; extra == 'asyncmy' Requires-Dist: asyncmy !=0.2.4,!=0.2.6,>=0.2.3 ; extra == 'asyncmy' Provides-Extra: mariadb_connector -Requires-Dist: mariadb !=1.1.2,!=1.1.5,>=1.0.1 ; extra == 'mariadb_connector' +Requires-Dist: mariadb !=1.1.10,!=1.1.2,!=1.1.5,>=1.0.1 ; extra == 'mariadb_connector' Provides-Extra: mssql Requires-Dist: pyodbc ; extra == 'mssql' Provides-Extra: mssql_pymssql diff --git a/libs/SQLAlchemy-2.0.27.dist-info/RECORD b/libs/SQLAlchemy-2.0.36.dist-info/RECORD similarity index 53% rename from libs/SQLAlchemy-2.0.27.dist-info/RECORD rename to libs/SQLAlchemy-2.0.36.dist-info/RECORD index 1563d2d52..5444bcc7c 100644 --- a/libs/SQLAlchemy-2.0.27.dist-info/RECORD +++ b/libs/SQLAlchemy-2.0.36.dist-info/RECORD @@ -1,135 +1,134 @@ -SQLAlchemy-2.0.27.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4 -SQLAlchemy-2.0.27.dist-info/LICENSE,sha256=PA9Zq4h9BB3mpOUv_j6e212VIt6Qn66abNettue-MpM,1100 -SQLAlchemy-2.0.27.dist-info/METADATA,sha256=fZGrNxgSqoY_vLjP6pXy7Ax_9Fvpy6P0SN_Qmjpaf8M,9602 -SQLAlchemy-2.0.27.dist-info/RECORD,, -SQLAlchemy-2.0.27.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 -SQLAlchemy-2.0.27.dist-info/WHEEL,sha256=UkJU7GAEhyIKVwGX1j1L5OGjUFT--MirwyCseqfCpZU,109 -SQLAlchemy-2.0.27.dist-info/top_level.txt,sha256=rp-ZgB7D8G11ivXON5VGPjupT1voYmWqkciDt5Uaw_Q,11 -sqlalchemy/__init__.py,sha256=s94qQVe-QqqRL9xhlih382KZikm_5rCLehCmTVeMkxo,13033 +SQLAlchemy-2.0.36.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4 +SQLAlchemy-2.0.36.dist-info/LICENSE,sha256=PA9Zq4h9BB3mpOUv_j6e212VIt6Qn66abNettue-MpM,1100 +SQLAlchemy-2.0.36.dist-info/METADATA,sha256=EZH514FydYtyOhgoZk_OF1ZQEtI4eTAEddlnUlRjzac,9692 +SQLAlchemy-2.0.36.dist-info/RECORD,, +SQLAlchemy-2.0.36.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +SQLAlchemy-2.0.36.dist-info/WHEEL,sha256=8MbRJsGMYV6Ym1_SoG8YuJPvArpN2Z2wVWusEpp3feg,108 +SQLAlchemy-2.0.36.dist-info/top_level.txt,sha256=rp-ZgB7D8G11ivXON5VGPjupT1voYmWqkciDt5Uaw_Q,11 +sqlalchemy/__init__.py,sha256=J2PsdiJiNW93Etxk6YN8o_C3TcpR1_DckU71r4LBcGE,13033 sqlalchemy/connectors/__init__.py,sha256=PzXPqZqi3BzEnrs1eW0DcsR4lyknAzhhN9rWcQ97hb4,476 sqlalchemy/connectors/aioodbc.py,sha256=GSTiNMO9h0qjPxgqaxDwWZ8HvhWMFNVR6MJQnN1oc40,5288 -sqlalchemy/connectors/asyncio.py,sha256=6s4hDYfuMjJ9KbJ4s7bF1fp5DmcgV77ozgZ5-bwZ0wc,5955 +sqlalchemy/connectors/asyncio.py,sha256=Hq2bkXmG6-KO_RfCrwMqx4oGH-uH1Z1WWKqPWNjz8p4,6138 sqlalchemy/connectors/pyodbc.py,sha256=t7AjyxIOnaWg3CrlUEpBs4Y5l0HFdNt3P_cSSKhbi0Y,8501 -sqlalchemy/cyextension/.gitignore,sha256=_4eLZEBj5Ht5WoM-WvbiDSycdViEzM62ucOZYPQWw9c,64 sqlalchemy/cyextension/__init__.py,sha256=GzhhN8cjMnDTE0qerlUlpbrNmFPHQWCZ4Gk74OAxl04,244 -sqlalchemy/cyextension/collections.cpython-38-darwin.so,sha256=T-PB8ecKVTCk2cZ2puq_f5XZg9d7gJEfgWTUEVD6sEo,258336 +sqlalchemy/cyextension/collections.cpython-38-darwin.so,sha256=JPCislATQznenBRmWIX9Ihqvfx99z04UDes848VPCfY,233760 sqlalchemy/cyextension/collections.pyx,sha256=L7DZ3DGKpgw2MT2ZZRRxCnrcyE5pU1NAFowWgAzQPEc,12571 -sqlalchemy/cyextension/immutabledict.cpython-38-darwin.so,sha256=hNhW_0eX4BbJKF-YaZoWSh8nFzoh-iWjs5pzdakZr5A,127440 +sqlalchemy/cyextension/immutabledict.cpython-38-darwin.so,sha256=vDvIl2ML414S5ZnbquknvGUbR52dNAfhltV86WMXnUA,94672 sqlalchemy/cyextension/immutabledict.pxd,sha256=3x3-rXG5eRQ7bBnktZ-OJ9-6ft8zToPmTDOd92iXpB0,291 sqlalchemy/cyextension/immutabledict.pyx,sha256=KfDTYbTfebstE8xuqAtuXsHNAK0_b5q_ymUiinUe_xs,3535 -sqlalchemy/cyextension/processors.cpython-38-darwin.so,sha256=NF8ouwV4ffK6AnAUb4ms9VaXC_5rc32Q16_cIy3HKKg,104648 +sqlalchemy/cyextension/processors.cpython-38-darwin.so,sha256=tl0ZZ_a97ftC1o6YcDdPwZRpvRgiAqiy3rz9SUo3a54,75976 sqlalchemy/cyextension/processors.pyx,sha256=R1rHsGLEaGeBq5VeCydjClzYlivERIJ9B-XLOJlf2MQ,1792 -sqlalchemy/cyextension/resultproxy.cpython-38-darwin.so,sha256=4UKCNsTKsvERmKn5kSDC3cT06p0t1kwjtJ1FrSslVcA,106752 +sqlalchemy/cyextension/resultproxy.cpython-38-darwin.so,sha256=cD-2VsfKRoe6o5w_iBR7bHGxAyqCF4pqMqeLjhqzu8o,78080 sqlalchemy/cyextension/resultproxy.pyx,sha256=eWLdyBXiBy_CLQrF5ScfWJm7X0NeelscSXedtj1zv9Q,2725 -sqlalchemy/cyextension/util.cpython-38-darwin.so,sha256=i39G3r6E1dWN3wVt0n-j_uQE074_rcD9uFxOMs5vFYY,125832 +sqlalchemy/cyextension/util.cpython-38-darwin.so,sha256=1ExFkSKS8idn9jWSlRTCI4v92JKAOrGZuS_Aoexd9fo,93064 sqlalchemy/cyextension/util.pyx,sha256=B85orxa9LddLuQEaDoVSq1XmAXIbLKxrxpvuB8ogV_o,2530 sqlalchemy/dialects/__init__.py,sha256=Kos9Gf5JZg1Vg6GWaCqEbD6e0r1jCwCmcnJIfcxDdcY,1770 sqlalchemy/dialects/_typing.py,sha256=hyv0nKucX2gI8ispB1IsvaUgrEPn9zEcq9hS7kfstEw,888 sqlalchemy/dialects/mssql/__init__.py,sha256=r5t8wFRNtBQoiUWh0WfIEWzXZW6f3D0uDt6NZTW_7Cc,1880 sqlalchemy/dialects/mssql/aioodbc.py,sha256=UQd9ecSMIML713TDnLAviuBVJle7P7i1FtqGZZePk2Y,2022 -sqlalchemy/dialects/mssql/base.py,sha256=2Tx9sC5bOd0JbweaMaqnjGOgESur7ZaaN1S66KMTwHk,133298 +sqlalchemy/dialects/mssql/base.py,sha256=msl_N_a_z8ali7Nthx55AGoV7b5wakCWvWu560BvH9o,132423 sqlalchemy/dialects/mssql/information_schema.py,sha256=HswjDc6y0mPXCf_x6VyylHlBdBa4PSY6Evxmmlch700,8084 sqlalchemy/dialects/mssql/json.py,sha256=evUACW2O62TAPq8B7QIPagz7jfc664ql9ms68JqiYzg,4816 -sqlalchemy/dialects/mssql/provision.py,sha256=RTVbgYLFAHzEnpVQDJroU8ji_10MqBTiZfyP9_-QNT4,5362 -sqlalchemy/dialects/mssql/pymssql.py,sha256=eZRLz7HGt3SdoZUjFBmA9BS43N7AhIASw7VPBPEJuG0,4038 +sqlalchemy/dialects/mssql/provision.py,sha256=ZAtt6Div9NLIngMs8kyloxfphw0KDNMsnRCAVd7-esE,5593 +sqlalchemy/dialects/mssql/pymssql.py,sha256=LAv43q4vBCB85OsAwHQItaQUYTYIO0QJ-jvzaBrswmY,4097 sqlalchemy/dialects/mssql/pyodbc.py,sha256=vwM-vBlmRwrqxOc73P0sFOrBSwn24wzc5IkEOpalbXQ,27056 sqlalchemy/dialects/mysql/__init__.py,sha256=bxbi4hkysUK2OOVvr1F49akUj1cky27kKb07tgFzI9U,2153 -sqlalchemy/dialects/mysql/aiomysql.py,sha256=67JrSUD1BmN88k_ASk6GvrttZFQiFjDY0wBiwdllxMk,9964 -sqlalchemy/dialects/mysql/asyncmy.py,sha256=CGILIRKf_2Ut9Ng2yBlmdg62laL-ockEm6GMuN7xlKE,10033 -sqlalchemy/dialects/mysql/base.py,sha256=KA7tvRxKUw0KwHwMth2rz-NWV0xMkVbYvPoBM9wrAFw,120850 +sqlalchemy/dialects/mysql/aiomysql.py,sha256=-oMZnCqNsSki8mlQRTWIwiQPT1OVdZIuANkb90q8LAs,9999 +sqlalchemy/dialects/mysql/asyncmy.py,sha256=YpuuOh8VknEeqHqUXQGfQ3jhfO3Xb-vZv78Jq5cscJ0,10067 +sqlalchemy/dialects/mysql/base.py,sha256=giGlZNGrKsNMoSkbzY0PGgfamKjA9rOkSq1o5vKvno4,122755 sqlalchemy/dialects/mysql/cymysql.py,sha256=eXT1ry0w_qRxjiO24M980c-8PZ9qSsbhqBHntjEiKB0,2300 sqlalchemy/dialects/mysql/dml.py,sha256=HXJMAvimJsqvhj3UZO4vW_6LkF5RqaKbHvklAjor7yU,7645 sqlalchemy/dialects/mysql/enumerated.py,sha256=ipEPPQqoXfFwcywNdcLlZCEzHBtnitHRah1Gn6nItcg,8448 sqlalchemy/dialects/mysql/expression.py,sha256=lsmQCHKwfPezUnt27d2kR6ohk4IRFCA64KBS16kx5dc,4097 sqlalchemy/dialects/mysql/json.py,sha256=l6MEZ0qp8FgiRrIQvOMhyEJq0q6OqiEnvDTx5Cbt9uQ,2269 sqlalchemy/dialects/mysql/mariadb.py,sha256=kTfBLioLKk4JFFst4TY_iWqPtnvvQXFHknLfm89H2N8,853 -sqlalchemy/dialects/mysql/mariadbconnector.py,sha256=VVRwKLb6GzDmitOM4wLNvmZw6RdhnIwkLl7IZfAmUy8,8734 -sqlalchemy/dialects/mysql/mysqlconnector.py,sha256=qiQdfLPze3QHuASAZ9iqRzD0hDW8FbKoQnfAEQCF7tM,5675 -sqlalchemy/dialects/mysql/mysqldb.py,sha256=9x_JiY4hj4tykG1ckuEGPyH4jCtsh4fgBhNukVnjUos,9658 -sqlalchemy/dialects/mysql/provision.py,sha256=4oGkClQ8jC3YLPF54sB4kCjFc8HRTwf5zl5zftAAXGo,3474 +sqlalchemy/dialects/mysql/mariadbconnector.py,sha256=_S1aV93kyP52Nvj7HR9weThML4oUvSLsLqiVFdoLR2o,8623 +sqlalchemy/dialects/mysql/mysqlconnector.py,sha256=oq3mtsNOMldUjs32JbJG2u3Hy3DObyVzUUMYfOkwkHg,5729 +sqlalchemy/dialects/mysql/mysqldb.py,sha256=qUBbA6STeYGozutyTxHCo5p1W3p59QFFS2FwCgPrjBA,9503 +sqlalchemy/dialects/mysql/provision.py,sha256=Jnk8UO9_Apd2odR2IQFLrscCfAmYxuBKcB8giS3bBog,3575 sqlalchemy/dialects/mysql/pymysql.py,sha256=GUnSHd2M2uKjmN46Hheymtm26g7phEgwYOXrX0zLY8M,4083 sqlalchemy/dialects/mysql/pyodbc.py,sha256=072crI4qVyPhajYvHnsfFeSrNjLFVPIjBQKo5uyz5yk,4297 -sqlalchemy/dialects/mysql/reflection.py,sha256=XXM8AGpaRTqDvuObg89Bzn_4h2ETG03viYBpWZJM3vc,22822 -sqlalchemy/dialects/mysql/reserved_words.py,sha256=Dm7FINIAkrKLoXmdu26SpE6V8LDCGyp734nmHV2tMd0,9154 -sqlalchemy/dialects/mysql/types.py,sha256=aPzx7hqqZ21aGwByEC-yWZUl6OpMvkbxwTqdN3OUGGI,24267 +sqlalchemy/dialects/mysql/reflection.py,sha256=3u34YwT1JJh3uThGZJZ3FKdnUcT7v08QB-tAl1r7VRk,22834 +sqlalchemy/dialects/mysql/reserved_words.py,sha256=ucKX2p2c3UnMq2ayZuOHuf73eXhu7SKsOsTlIN1Q83I,9258 +sqlalchemy/dialects/mysql/types.py,sha256=L5cTCsMT1pTedszNEM3jSxFNZEMcHQLprYCZ0vmfsnA,24343 sqlalchemy/dialects/oracle/__init__.py,sha256=p4-2gw7TT0bX_MoJXTGD4i8WHctYsK9kCRbkpzykBrc,1493 -sqlalchemy/dialects/oracle/base.py,sha256=-7b5iubFPxJyDRoLXlxj8rk8eBRN2_IdZlB2zzzrrbw,118246 -sqlalchemy/dialects/oracle/cx_oracle.py,sha256=t5yH4svVz7xoDSITF958blgZ01hbCUEWUKrAXwiCiAE,55566 +sqlalchemy/dialects/oracle/base.py,sha256=zLMZedrr6j1LvJz4qYnoSjikI5RZY92YFeQHiZ_YvW0,119676 +sqlalchemy/dialects/oracle/cx_oracle.py,sha256=q8Nyj15UZCE2TWOmxuWp5ZsxiCiGMzqfd_9UkmjIja0,55235 sqlalchemy/dialects/oracle/dictionary.py,sha256=7WMrbPkqo8ZdGjaEZyQr-5f2pajSOF1OTGb8P97z8-g,19519 -sqlalchemy/dialects/oracle/oracledb.py,sha256=UFcZwrrk0pWfAp_SKJZ1B5rIQHtNhOvuu73_JaSnTbI,9487 +sqlalchemy/dialects/oracle/oracledb.py,sha256=fZRKGqNIwW9LG4i8yDOXABrucbfzn_yC86Od-BJ3PcM,13619 sqlalchemy/dialects/oracle/provision.py,sha256=O9ZpF4OG6Cx4mMzLRfZwhs8dZjrJETWR402n9c7726A,8304 sqlalchemy/dialects/oracle/types.py,sha256=QK3hJvWzKnnCe3oD3rItwEEIwcoBze8qGg7VFOvVlIk,8231 -sqlalchemy/dialects/postgresql/__init__.py,sha256=kwgzMhtZKDHD12HMGo5MtdKCnDdy6wLezDGZPOEoU3Q,3895 +sqlalchemy/dialects/postgresql/__init__.py,sha256=wwnNAq4wDQzrlPRzDNB06ayuq3L2HNO99nzeEvq-YcU,3892 sqlalchemy/dialects/postgresql/_psycopg_common.py,sha256=7TudtgsPiSB8O5kX8W8KxcNYR8t5h_UHb86b_ChL0P8,5696 -sqlalchemy/dialects/postgresql/array.py,sha256=9dJ_1WjWSBX1-MGDZtJACJ38vtRO3da7d4UId79WsnQ,13713 -sqlalchemy/dialects/postgresql/asyncpg.py,sha256=12DN8hlK-Na_bEFmQ5kXK7MRqu87ze2IMX8aDyiSddU,40183 -sqlalchemy/dialects/postgresql/base.py,sha256=ogY8rcQvT9jjYdtStxZ_nhTl8LmhB_zeJyhZIaUyMLk,176486 +sqlalchemy/dialects/postgresql/array.py,sha256=bWcame7ntmI_Kx6gmBX0-chwADFdLHeCvaDQ4iX8id8,13734 +sqlalchemy/dialects/postgresql/asyncpg.py,sha256=9P0Itn9eeSBu67kGSsHuzx8xd4YYwRKdiZ5m7bF5onU,41074 +sqlalchemy/dialects/postgresql/base.py,sha256=dGPsaV3Esw6-AwE3QcgHF0Fray3Yw5-gLLgCvgdxvS0,179083 sqlalchemy/dialects/postgresql/dml.py,sha256=Pc69Le6qzmUHHb1FT5zeUSD31dWm6SBgdCAGW89cs3s,11212 sqlalchemy/dialects/postgresql/ext.py,sha256=1bZ--iNh2O9ym7l2gXZX48yP3yMO4dqb9RpYro2Mj2Q,16262 sqlalchemy/dialects/postgresql/hstore.py,sha256=otAx-RTDfpi_tcXkMuQV0JOIXtYgevgnsikLKKOkI6U,11541 -sqlalchemy/dialects/postgresql/json.py,sha256=-ffnp85fQBOyt0Bjb7XAupmOxloUdzFZZgixUG3Wj5w,11212 -sqlalchemy/dialects/postgresql/named_types.py,sha256=SFhs9_l108errKNuAMPl761RQ2imTO9PbUAnSv-WtRg,17100 +sqlalchemy/dialects/postgresql/json.py,sha256=53rQWon9cUXd1yCjIvUpJjWwNyRSy3U7Kz0HV70ftrc,11618 +sqlalchemy/dialects/postgresql/named_types.py,sha256=3IV1ufo7zJjKmX4VtGDEnoXE6xEqLJAtGG82IiqHXwY,17594 sqlalchemy/dialects/postgresql/operators.py,sha256=NsAaWun_tL3d_be0fs9YL6T4LPKK6crnmFxxIJHgyeY,2808 sqlalchemy/dialects/postgresql/pg8000.py,sha256=3yoekiWSF-xnaWMqG76XrYPMqerg-42TdmfsW_ivK9E,18640 -sqlalchemy/dialects/postgresql/pg_catalog.py,sha256=nAKavWTE_4cqxiDKDTdo-ivkCxxRIlzD5GO9Wl1yrG4,8884 -sqlalchemy/dialects/postgresql/provision.py,sha256=yqyx-aDFO9l2YcL9f4T5HBP_Lnt5dHsMjpuXUG8mi7A,5762 -sqlalchemy/dialects/postgresql/psycopg.py,sha256=TF53axr1EkTBAZD85JCq6wA7XTcJTzXueSz26txDbgc,22364 -sqlalchemy/dialects/postgresql/psycopg2.py,sha256=gAP3poHDUxEB6iut6sxe9PhBiOrV_iIMvnP0NUlC-Rw,31607 +sqlalchemy/dialects/postgresql/pg_catalog.py,sha256=hY3NXEUHxTWD4umhd2aowNu3laC-61Q_qQ_pReyXTUM,9254 +sqlalchemy/dialects/postgresql/provision.py,sha256=t6TZj0XaWG9zrpCjNr0oJRjAC_WQzaNdp3kaKJIbS8I,5770 +sqlalchemy/dialects/postgresql/psycopg.py,sha256=Uwf45f9fInOtaExiEdwiP9xzRo7hw0XyZTkRtgdom44,23168 +sqlalchemy/dialects/postgresql/psycopg2.py,sha256=kwEnflz5bAqJcuO_20eYiCtha_a4m_tg5_lppdDnaeU,31998 sqlalchemy/dialects/postgresql/psycopg2cffi.py,sha256=M7wAYSL6Pvt-4nbfacAHGyyw4XMKJ_bQZ1tc1pBtIdg,1756 sqlalchemy/dialects/postgresql/ranges.py,sha256=6CgV7qkxEMJ9AQsiibo_XBLJYzGh-2ZxpG83sRaesVY,32949 sqlalchemy/dialects/postgresql/types.py,sha256=Jfxqw9JaKNOq29JRWBublywgb3lLMyzx8YZI7CXpS2s,7300 sqlalchemy/dialects/sqlite/__init__.py,sha256=lp9DIggNn349M-7IYhUA8et8--e8FRExWD2V_r1LJk4,1182 -sqlalchemy/dialects/sqlite/aiosqlite.py,sha256=OMvxP2eWyqk5beF-sHhzxRmjzO4VCQp55q7NH2XPVTE,12305 -sqlalchemy/dialects/sqlite/base.py,sha256=lUtigjn7NdPBq831zQsLcBwdwRJqdgKM_tUaDrMElOE,96794 +sqlalchemy/dialects/sqlite/aiosqlite.py,sha256=g3qGV6jmiXabWyb3282g_Nmxtj1jThxGSe9C9yalb-U,12345 +sqlalchemy/dialects/sqlite/base.py,sha256=LcnW6hzxqTtPlDBOInHumvuDt8a31THA5Jnm4vFvdFI,97811 sqlalchemy/dialects/sqlite/dml.py,sha256=9GE55WvwoktKy2fHeT-Wbc9xPHgsbh5oBfd_fckMH5Q,8443 sqlalchemy/dialects/sqlite/json.py,sha256=Eoplbb_4dYlfrtmQaI8Xddd2suAIHA-IdbDQYM-LIhs,2777 sqlalchemy/dialects/sqlite/provision.py,sha256=UCpmwxf4IWlrpb2eLHGbPTpCFVbdI_KAh2mKtjiLYao,5632 sqlalchemy/dialects/sqlite/pysqlcipher.py,sha256=OL2S_05DK9kllZj6DOz7QtEl7jI7syxjW6woS725ii4,5356 -sqlalchemy/dialects/sqlite/pysqlite.py,sha256=TAOqsHIjhbUZOF_Qk7UooiekkVZNhYJNduxlGQjokeA,27900 +sqlalchemy/dialects/sqlite/pysqlite.py,sha256=aDp47n0J509kl2hDchoaBKXEQVZtkux54DwfKytUAe4,28068 sqlalchemy/dialects/type_migration_guidelines.txt,sha256=-uHNdmYFGB7bzUNT6i8M5nb4j6j9YUKAtW4lcBZqsMg,8239 sqlalchemy/engine/__init__.py,sha256=Stb2oV6l8w65JvqEo6J4qtKoApcmOpXy3AAxQud4C1o,2818 sqlalchemy/engine/_py_processors.py,sha256=j9i_lcYYQOYJMcsDerPxI0sVFBIlX5sqoYMdMJlgWPI,3744 sqlalchemy/engine/_py_row.py,sha256=wSqoUFzLOJ1f89kgDb6sJm9LUrF5LMFpXPcK1vUsKcs,3787 sqlalchemy/engine/_py_util.py,sha256=f2DI3AN1kv6EplelowesCVpwS8hSXNufRkZoQmJtSH8,2484 -sqlalchemy/engine/base.py,sha256=NGD1iokXsJBw_6sBOpX4STo_05fQFd52qUl1YiJZsdU,122038 -sqlalchemy/engine/characteristics.py,sha256=Qbvt4CPrggJ3GfxHl0hOAxopjnCQy-W_pjtwLIe-Q1g,2590 -sqlalchemy/engine/create.py,sha256=5Me7rgLvmZVJM6QzoH8aBHz0lIratA2vXN8cW6kUgdY,32872 -sqlalchemy/engine/cursor.py,sha256=jSjpGM5DiwX1pwEHGx3wyqgHrgj8rwU5ZpVvMv5GaJs,74443 -sqlalchemy/engine/default.py,sha256=VSqSm-juosz-5WqZPWjgDQf8Fra27M-YsrVVcs7RwPU,84672 +sqlalchemy/engine/base.py,sha256=frWSMmt3dlentYH4QNN3cijdGzp8NbunColUZwWsWgI,122958 +sqlalchemy/engine/characteristics.py,sha256=N3kbvw_ApMh86wb5yAGnxtPYD4YRhYMWion1H_aVZBI,4765 +sqlalchemy/engine/create.py,sha256=mYJtOG2ZKM8sgyfjpGpamW15RDU7JXi5s6iibbJHMIs,33206 +sqlalchemy/engine/cursor.py,sha256=cFq61yrw76k-QR_xNUBWuL-Zeyb14ltG-6jo2Q2iuuw,76392 +sqlalchemy/engine/default.py,sha256=2wwKKdsagb3QTajRSEw8Hl-EnQ-LmRxy822xOGyenHc,84648 sqlalchemy/engine/events.py,sha256=c0unNFFiHzTAvkUtXoJaxzMFMDwurBkHiiUhuN8qluc,37381 -sqlalchemy/engine/interfaces.py,sha256=gktNzgLjNK-KrYMU__Lk0h85SXQI8LCjDakkuLxagNE,112688 +sqlalchemy/engine/interfaces.py,sha256=fcVHOmnMo7JZLHzgSKoK3QsdVHH7kJ_AmrDvwW9Ka3k,112936 sqlalchemy/engine/mock.py,sha256=yvpxgFmRw5G4QsHeF-ZwQGHKES-HqQOucTxFtN1uzdk,4179 sqlalchemy/engine/processors.py,sha256=XyfINKbo-2fjN-mW55YybvFyQMOil50_kVqsunahkNs,2379 -sqlalchemy/engine/reflection.py,sha256=FlT5kPpKm7Lah50GNt5XcnlJWojTL3LD_x0SoCF9kfY,75127 -sqlalchemy/engine/result.py,sha256=j6BI4Wj2bziQNQG5OlG_Cm4KcNWY9AoYvTXVlJUU-D8,77603 +sqlalchemy/engine/reflection.py,sha256=gwGs8y7x6py5z-ZWx3hQqQrwpHepMCTJyQcFwWJjPlw,75364 +sqlalchemy/engine/result.py,sha256=NZEskTMAcDzK-vjE96Fw8VvBL58s5Y6rt9vXcmZdM4w,77651 sqlalchemy/engine/row.py,sha256=9AAQo9zYDL88GcZ3bjcQTwMT-YIcuGTSMAyTfmBJ_yM,12032 sqlalchemy/engine/strategies.py,sha256=DqFSWaXJPL-29Omot9O0aOcuGL8KmCGyOvnPGDkAJoE,442 sqlalchemy/engine/url.py,sha256=8eWkUaIUyDExOcJ2D4xJXRcn4OY1GQJ3Q2duSX6UGAg,30784 -sqlalchemy/engine/util.py,sha256=hkEql1t19WHl6uzR55-F-Fs_VMCJ7p02KKQVNUDSXTk,5667 +sqlalchemy/engine/util.py,sha256=bNirO8k1S8yOW61uNH-a9QrWtAJ9VGFgbiR0lk1lUQU,5682 sqlalchemy/event/__init__.py,sha256=KBrp622xojnC3FFquxa2JsMamwAbfkvzfv6Op0NKiYc,997 -sqlalchemy/event/api.py,sha256=BUTAZjSlzvq4Hn2v2pihP_P1yo3lvCVDczK8lV_XJ80,8227 +sqlalchemy/event/api.py,sha256=DtDVgjKSorOfp9MGJ7fgMWrj4seC_hkwF4D8CW1RFZU,8226 sqlalchemy/event/attr.py,sha256=X8QeHGK4ioSYht1vkhc11f606_mq_t91jMNIT314ubs,20751 -sqlalchemy/event/base.py,sha256=3n9FmUkcXYHHyGzfpjKDsrIUVCNST_hq4zOtrNm0_a4,14954 +sqlalchemy/event/base.py,sha256=270OShTD17-bSFUFnPtKdVnB0NFJZ2AouYPo1wT0aJw,15127 sqlalchemy/event/legacy.py,sha256=teMPs00fO-4g8a_z2omcVKkYce5wj_1uvJO2n2MIeuo,8227 sqlalchemy/event/registry.py,sha256=nfTSSyhjZZXc5wseWB4sXn-YibSc0LKX8mg17XlWmAo,10835 sqlalchemy/events.py,sha256=k-ZD38aSPD29LYhED7CBqttp5MDVVx_YSaWC2-cu9ec,525 sqlalchemy/exc.py,sha256=M_8-O1hd8i6gbyx-TapV400p_Lxq2QqTGMXUAO-YgCc,23976 sqlalchemy/ext/__init__.py,sha256=S1fGKAbycnQDV01gs-JWGaFQ9GCD4QHwKcU2wnugg_o,322 -sqlalchemy/ext/associationproxy.py,sha256=5O5ANHARO8jytvqBQmOu-QjNVE4Hh3tfYquqKAj5ajs,65771 +sqlalchemy/ext/associationproxy.py,sha256=ZGc_ssGf7FC6eKrja1iTvnWEKLkFZQA8CiVAjR8iVRw,66062 sqlalchemy/ext/asyncio/__init__.py,sha256=1OqSxEyIUn7RWLGyO12F-jAUIvk1I6DXlVy80-Gvkds,1317 sqlalchemy/ext/asyncio/base.py,sha256=fl7wxZD9KjgFiCtG3WXrYjHEvanamcsodCqq9pH9lOk,8905 -sqlalchemy/ext/asyncio/engine.py,sha256=vQRdpBnGuyzyG48ZssDZvFlcS6Y6ZXUYI0GEOQqdDxk,47941 +sqlalchemy/ext/asyncio/engine.py,sha256=S_IRWX4QAjj2veLSu4Y3gKBIXkKQt7_2StJAK2_KUDY,48190 sqlalchemy/ext/asyncio/exc.py,sha256=8sII7VMXzs2TrhizhFQMzSfcroRtiesq8o3UwLfXSgQ,639 -sqlalchemy/ext/asyncio/result.py,sha256=ID2eh-NHW-lnNFTxbKhje8fr-tnsucUsiw_jcpGcSPc,30409 -sqlalchemy/ext/asyncio/scoping.py,sha256=BmE1UbFV_C4BXB4WngJc523DeMH-nTchNb8ORiSPYfE,52597 -sqlalchemy/ext/asyncio/session.py,sha256=Zhkrwwc4rqZJntUpzbgruQNgpuOwaRmjrBQb8ol19z0,62894 -sqlalchemy/ext/automap.py,sha256=hBlKAfZn2fgAAQh7vh4f2kClbb5ryOgV59tzVHEObQM,61389 +sqlalchemy/ext/asyncio/result.py,sha256=3rbVIY_wySi50JwaK3Kf2qa3c5Fc8W84FtUpt-9i9Vk,30477 +sqlalchemy/ext/asyncio/scoping.py,sha256=UxHAFxtWKqA7TEozyN2h7MJyzSspTCrS-1SlgQLTExo,52608 +sqlalchemy/ext/asyncio/session.py,sha256=QpXnqspwYnT28znD1EdpUIaVjQOO1BirtS0BJeBxeZk,63087 +sqlalchemy/ext/automap.py,sha256=r0mUSyogNyqdBL4m9AA1NXbLiTLQmtvyQymsssNEipo,61581 sqlalchemy/ext/baked.py,sha256=H6T1il7GY84BhzPFj49UECSpZh_eBuiHomA-QIsYOYQ,17807 -sqlalchemy/ext/compiler.py,sha256=ONPoxoKD2yUS9R2-oOhmPsA7efm-Bs0BXo7HE1dGlsU,20391 +sqlalchemy/ext/compiler.py,sha256=6X6sZCAo9v-PQfLbwBSYQUK0-XH2xTE5Jm0Zg6Ka6eM,20877 sqlalchemy/ext/declarative/__init__.py,sha256=20psLdFQbbOWfpdXHZ0CTY6I1k4UqXvKemNVu1LvPOI,1818 sqlalchemy/ext/declarative/extensions.py,sha256=uCjN1GisQt54AjqYnKYzJdUjnGd2pZBW47WWdPlS7FE,19547 sqlalchemy/ext/horizontal_shard.py,sha256=wuwAPnHymln0unSBnyx-cpX0AfESKSsypaSQTYCvzDk,16750 -sqlalchemy/ext/hybrid.py,sha256=LXph2NOtBQj6rZMi5ar-WCxkY7qaFp-o-UFIvCy-ep0,52432 +sqlalchemy/ext/hybrid.py,sha256=IYkCaPZ29gm2cPKPg0cWMkLCEqMykD8-JJTvgacGbmc,52458 sqlalchemy/ext/indexable.py,sha256=UkTelbydKCdKelzbv3HWFFavoET9WocKaGRPGEOVfN8,11032 sqlalchemy/ext/instrumentation.py,sha256=sg8ghDjdHSODFXh_jAmpgemnNX1rxCeeXEG3-PMdrNk,15707 sqlalchemy/ext/mutable.py,sha256=L5ZkHBGYhMaqO75Xtyrk2DBR44RDk0g6Rz2HzHH0F8Q,37355 @@ -137,111 +136,111 @@ sqlalchemy/ext/mypy/__init__.py,sha256=0WebDIZmqBD0OTq5JLtd_PmfF9JGxe4d4Qv3Ml3PK sqlalchemy/ext/mypy/apply.py,sha256=Aek_-XA1eXihT4attxhfE43yBKtCgsxBSb--qgZKUqc,10550 sqlalchemy/ext/mypy/decl_class.py,sha256=1vVJRII2apnLTUbc5HkJS6Z2GueaUv_eKvhbqh7Wik4,17384 sqlalchemy/ext/mypy/infer.py,sha256=KVnmLFEVS33Al8pUKI7MJbJQu3KeveBUMl78EluBORw,19369 -sqlalchemy/ext/mypy/names.py,sha256=IQ16GLZFqKxfYxIZxkbTurBqOUYbUV-64V_DSRns1tc,10630 +sqlalchemy/ext/mypy/names.py,sha256=Q3ef8XQBgVm9WUwlItqlYCXDNi_kbV5DdLEgbtEMEI8,10479 sqlalchemy/ext/mypy/plugin.py,sha256=74ML8LI9xar0V86oCxnPFv5FQGEEfUzK64vOay4BKFs,9750 -sqlalchemy/ext/mypy/util.py,sha256=1zuDQG8ezmF-XhJmAQU_lcBHiD--sL-lq20clg8t4lE,9448 +sqlalchemy/ext/mypy/util.py,sha256=DKRaurkXHI2lAMAAcEO5GLXbX_m2Xqy7l_juh8Byf5U,9960 sqlalchemy/ext/orderinglist.py,sha256=TGYbsGH72wEZcFNQDYDsZg9OSPuzf__P8YX8_2HtYUo,14384 -sqlalchemy/ext/serializer.py,sha256=YemanWdeMVUDweHCnQc-iMO6mVVXNo2qQ5NK0Eb2_Es,6178 +sqlalchemy/ext/serializer.py,sha256=D0g4jMZkRk0Gjr0L-FZe81SR63h0Zs-9JzuWtT_SD7k,6140 sqlalchemy/future/__init__.py,sha256=q2mw-gxk_xoxJLEvRoyMha3vO1xSRHrslcExOHZwmPA,512 sqlalchemy/future/engine.py,sha256=AgIw6vMsef8W6tynOTkxsjd6o_OQDwGjLdbpoMD8ue8,495 sqlalchemy/inspection.py,sha256=MF-LE358wZDUEl1IH8-Uwt2HI65EsQpQW5o5udHkZwA,5063 sqlalchemy/log.py,sha256=8x9UR3nj0uFm6or6bQF-JWb4fYv2zOeQjG_w-0wOJFA,8607 sqlalchemy/orm/__init__.py,sha256=ZYys5nL3RFUDCMOLFDBrRI52F6er3S1U1OY9TeORuKs,8463 -sqlalchemy/orm/_orm_constructors.py,sha256=VWY_MotbcQlECGx2uwEu3IcRkZ4RgLM_ufPad3IA9ZM,99354 +sqlalchemy/orm/_orm_constructors.py,sha256=8EQfYsDL2k_ev0eK-wxMl3algouczN38Gu43CrRlAlo,103434 sqlalchemy/orm/_typing.py,sha256=DVBfpHmDVK4x1zxaGJPY2GoTrAsyR6uexv20Lzf1afc,4973 -sqlalchemy/orm/attributes.py,sha256=-IGg2RFjOPwAEr-boohvlZfitz7OtaXz1v8-7uG8ekw,92520 -sqlalchemy/orm/base.py,sha256=HhuarpRU-BpKSHE1LeeBaG8CpdkwwLrvTkUVRK-ofjg,27424 -sqlalchemy/orm/bulk_persistence.py,sha256=SSSR0Omv8A8BzpsOdSo4x75XICoqGpO1sUkyEWUVGso,70022 -sqlalchemy/orm/clsregistry.py,sha256=29LyYiuj0qbebOpgW6DbBPNB2ikTweFQar1byCst7I0,17958 -sqlalchemy/orm/collections.py,sha256=jpMsJGVixmrW9kfT8wevm9kpatKRqyDLcqWd7CjKPxE,52179 -sqlalchemy/orm/context.py,sha256=Wjx0d1Rkxd-wsX1mP2V2_4VbOxdNY6S_HijdXJ-TtKg,112001 -sqlalchemy/orm/decl_api.py,sha256=0gCZWM2sOXb_4OzUXfevVUisZWOUrErQTAHyaSQQL5k,63674 -sqlalchemy/orm/decl_base.py,sha256=Tq6I3Jm3bkM01mmoiHfdFXLE94YDk1ik2u2dXL1RxLc,81601 +sqlalchemy/orm/attributes.py,sha256=lorOHBJvJJYndOuafWJhHBbQ1pR6FAyimhqz-mErBRQ,92534 +sqlalchemy/orm/base.py,sha256=FXkYTSCDUJFQSB5pcyPt2wG-dRctf5P6ySjyjVxQsX0,27502 +sqlalchemy/orm/bulk_persistence.py,sha256=1FC23bRJKjpfbp2D5hYuV1qOVIKGSswu9XPXbbSJ5Mo,72663 +sqlalchemy/orm/clsregistry.py,sha256=IjoDZwWpjG42ji59L4M1EZvjBEoXPZykzENDtKWxU8A,17974 +sqlalchemy/orm/collections.py,sha256=WEKuUCRgLhDhJEIBhZ21UrE0pBOyRm2zxD20GvbgA9g,52243 +sqlalchemy/orm/context.py,sha256=FMPyw07OA9OXWQ32RQx52AEa2xTLSkqdYgx9R_yN1x0,112955 +sqlalchemy/orm/decl_api.py,sha256=_WPKQ_vSE5k2TLtNmkaxxYmvbhZvkRMrrvCeDxdqDQE,63998 +sqlalchemy/orm/decl_base.py,sha256=8R7go5sULTYNRlhYiEjXIJkQ34oPp7DY_fC2nS5D5is,83343 sqlalchemy/orm/dependency.py,sha256=hgjksUWhgbmgHK5GdJdiDCBgDAIGQXIrY-Tj79tbL2k,47631 -sqlalchemy/orm/descriptor_props.py,sha256=pKtpP7H1LB_YuHRVrEYpfFZybEnUUdPwQXxduYFe2hA,37180 -sqlalchemy/orm/dynamic.py,sha256=jksBDCOsm6EBMVParcNGuMeaAv12hX4IzouKspC-HPA,9786 -sqlalchemy/orm/evaluator.py,sha256=q292K5vdpP69G7Z9y1RqI5GFAk2diUPwnsXE8De_Wgw,11925 -sqlalchemy/orm/events.py,sha256=_Ttun_bCSGgvsfg-VzAeEcsGpacf8p4c5z12JkSQkjM,127697 -sqlalchemy/orm/exc.py,sha256=w7MZkJMGGlu5J6jOFSmi9XXzc02ctnTv34jrEWpI-eM,7356 +sqlalchemy/orm/descriptor_props.py,sha256=dR_h4Gvdtpcdp4sj_ZOR4P5Nng2J2vhsvFHouRLlntc,37244 +sqlalchemy/orm/dynamic.py,sha256=rWAZ-nfAkREuNjt8e_FRdqYrvHDdbODn1CcfyP8Y18k,9816 +sqlalchemy/orm/evaluator.py,sha256=tRETz4dNZ71VsEA8nG0hpefByB-W0zBt02IxcSR5H2g,12353 +sqlalchemy/orm/events.py,sha256=1PiGT7JMUWTDAb3X1T79P02BMVDmcWEpatz1FwpLqoA,127777 +sqlalchemy/orm/exc.py,sha256=IP40P-wOeXhkYk0YizuTC3wqm6W9cPTaQU08f5MMaQ0,7413 sqlalchemy/orm/identity.py,sha256=jHdCxCpCyda_8mFOfGmN_Pr0XZdKiU-2hFZshlNxbHs,9249 sqlalchemy/orm/instrumentation.py,sha256=M-kZmkUvHUxtf-0mCA8RIM5QmMH1hWlYR_pKMwaidjA,24321 -sqlalchemy/orm/interfaces.py,sha256=1yyppjMHcP5NPXjxfOlSeFNmc-3_T_o2upeF3KFZtc0,48378 -sqlalchemy/orm/loading.py,sha256=JN2zLnPjNnk7K9DERbyerxESCXin7m7X1XP0gfdWLOE,57537 -sqlalchemy/orm/mapped_collection.py,sha256=3cneB1dfPTLrsTvKoo9_oCY2xtq4UAHfe5WSXPyqIS4,19690 -sqlalchemy/orm/mapper.py,sha256=8SVHr7tO-DDNpNGi68usc7PLQ7mTwzkZNEJu1aMb6dQ,171059 -sqlalchemy/orm/path_registry.py,sha256=bIXllBRevK7Ic5irajYnZgl2iazJ0rKNRkhXJSlfxjY,25850 +sqlalchemy/orm/interfaces.py,sha256=7Lni4Cue41b1CsmN4VbeUyWwzuNMcKtkrpihc9U-WIw,48690 +sqlalchemy/orm/loading.py,sha256=9RacpzFOWbuKgPRWHFmyIvD4fYCLAnkpwBFASyQ2CoI,58277 +sqlalchemy/orm/mapped_collection.py,sha256=zK3d3iozORzDruBUrAmkVC0RR3Orj5szk-TSQ24xzIU,19682 +sqlalchemy/orm/mapper.py,sha256=W-srpoEc3UIYv_6qTXTd_dG_TVeQcToG77VGrXt85PM,171738 +sqlalchemy/orm/path_registry.py,sha256=sJZMv_WPqUpHfQtKWaX3WYFeKBcNJ8C3wOM2mkBGkTE,25920 sqlalchemy/orm/persistence.py,sha256=dzyB2JOXNwQgaCbN8kh0sEz00WFePr48qf8NWVCUZH8,61701 -sqlalchemy/orm/properties.py,sha256=81I-PIF7f7bB0qdH4BCYMWzCzRpe57yUiEIPv2tzBoA,29127 -sqlalchemy/orm/query.py,sha256=UVNWn_Rq4a8agh5UUWNeu0DJQtPceCWVpXx1-uW7A4E,117555 -sqlalchemy/orm/relationships.py,sha256=DqD3WBKpeVQ59ldh6eCxar_sIToA3tc2-bJPtp3zfpM,127709 -sqlalchemy/orm/scoping.py,sha256=gFYywLeMmd5qjFdVPzeuCX727mTaChrCv8aqn4wPke0,78677 -sqlalchemy/orm/session.py,sha256=yiKyoJBARQj4I1ZBjsIxc6ecCpt2Upjvlxruo2A5HRc,193181 -sqlalchemy/orm/state.py,sha256=mW2f1hMSNeTJ89foutOE1EqLLD6DZkrSeO-pgagZweg,37520 +sqlalchemy/orm/properties.py,sha256=eDPFzxYUgdM3uWjHywnb1XW-i0tVKKyx7A2MCD31GQU,29306 +sqlalchemy/orm/query.py,sha256=Cf0e94-u1XyoXJoOAmr4iFvtCwNY98kxUYyMPenaWTE,117708 +sqlalchemy/orm/relationships.py,sha256=dS5SY0v1MiD7iCNnAQlHaI6prUQhL5EkXT7ijc8FR8E,128644 +sqlalchemy/orm/scoping.py,sha256=rJVc7_Lic4V00HZ-UvYFWkVpXqdrMayRmIs4fIwH1UA,78688 +sqlalchemy/orm/session.py,sha256=CZJTQ-wPwIy0c3AMFxgJnBgaft6eEf4JzcCLcaaCSjg,195979 +sqlalchemy/orm/state.py,sha256=327-F4TG29s6mLC8oWRiO2PuvYIUZzY1MqUPjtUy7M4,37670 sqlalchemy/orm/state_changes.py,sha256=qKYg7NxwrDkuUY3EPygAztym6oAVUFcP2wXn7QD3Mz4,6815 -sqlalchemy/orm/strategies.py,sha256=OtmMtWpCDk4ZiaM_ipzGn80sPOi6Opwj3Co4lUHpd_w,114206 -sqlalchemy/orm/strategy_options.py,sha256=RbFl-79Lrh8XIVUnZFmQ5GVvR586SG_szs3prw5DZLQ,84204 +sqlalchemy/orm/strategies.py,sha256=-tsBRsmEqkaxAAIn4t2F-U5SrRIPoPCyzpqFYGTAwNs,119866 +sqlalchemy/orm/strategy_options.py,sha256=oeDl_rMDNAC_90N7ytsni-psXWAeQMhABQFyKBSmai0,85353 sqlalchemy/orm/sync.py,sha256=g7iZfSge1HgxMk9SKRgUgtHEbpbZ1kP_CBqOIdTOXqc,5779 sqlalchemy/orm/unitofwork.py,sha256=fiVaqcymbDDHRa1NjS90N9Z466nd5pkJOEi1dHO6QLY,27033 -sqlalchemy/orm/util.py,sha256=MSLKAWZNw3FzFTH094xpfrhoA2Ov5pJQinK_dU_M0zo,80351 +sqlalchemy/orm/util.py,sha256=5SC4MOVU0cPObexDjpMvXvetueiU5pze42raL94gj24,81021 sqlalchemy/orm/writeonly.py,sha256=SYu2sAaHZONk2pW4PmtE871LG-O0P_bjidvKzY1H_zI,22305 sqlalchemy/pool/__init__.py,sha256=qiDdq4r4FFAoDrK6ncugF_i6usi_X1LeJt-CuBHey0s,1804 sqlalchemy/pool/base.py,sha256=WF4az4ZKuzQGuKeSJeyexaYjmWZUvYdC6KIi8zTGodw,52236 sqlalchemy/pool/events.py,sha256=xGjkIUZl490ZDtCHqnQF9ZCwe2Jv93eGXmnQxftB11E,13147 -sqlalchemy/pool/impl.py,sha256=2k2YMY9AepEoqGD_ClP_sUodSoa6atkt3GLPPWI49i4,17717 +sqlalchemy/pool/impl.py,sha256=JwpALSkH-pCoO_6oENbkHYY00Jx9nlttyoI61LivRNc,18944 sqlalchemy/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 sqlalchemy/schema.py,sha256=dKiWmgHYjcKQ4TiiD6vD0UMmIsD8u0Fsor1M9AAeGUs,3194 sqlalchemy/sql/__init__.py,sha256=UNa9EUiYWoPayf-FzNcwVgQvpsBdInPZfpJesAStN9o,5820 sqlalchemy/sql/_dml_constructors.py,sha256=YdBJex0MCVACv4q2nl_ii3uhxzwU6aDB8zAsratX5UQ,3867 -sqlalchemy/sql/_elements_constructors.py,sha256=rrxq5Nzyo7GbLaGxb8VRZxko2nW0z7SYEli8ArfMIXI,62550 +sqlalchemy/sql/_elements_constructors.py,sha256=833Flez92odZkE2Vy6SXK8LcoO1AwkfVzOnATJLWFsA,63168 sqlalchemy/sql/_orm_types.py,sha256=T-vjcry4C1y0GToFKVxQCnmly_-Zsq4IO4SHN6bvUF4,625 sqlalchemy/sql/_py_util.py,sha256=hiM9ePbRSGs60bAMxPFuJCIC_p9SQ1VzqXGiPchiYwE,2173 sqlalchemy/sql/_selectable_constructors.py,sha256=wjE6HrLm9cR7bxvZXT8sFLUqT6t_J9G1XyQCnYmBDl0,18780 -sqlalchemy/sql/_typing.py,sha256=Z3tBzapYRP0sKL7IwqnzPE9b8Bbq9vQtc4iV9tvxDoU,12494 +sqlalchemy/sql/_typing.py,sha256=oqwrYHVMtK-AuKGH9c4SgfiOEJUt5vjkzSEzzscMHkM,12771 sqlalchemy/sql/annotation.py,sha256=aqbbVz9kfbCT3_66CZ9GEirVN197Cukoqt8rq48FgkQ,18245 -sqlalchemy/sql/base.py,sha256=2MVQnIL0b8xuzp1Fcv0NAye6h_OcgYpsUgLB4sy8Ahk,73756 -sqlalchemy/sql/cache_key.py,sha256=sJhAA-2jovIteeIU7mw9hSL1liPP9Ez89CZZJFC3h6E,33548 -sqlalchemy/sql/coercions.py,sha256=1xzN_9U5BCJGgokdc5iYj5o2cMAfEEZkr1Oa9Q-JYj8,40493 -sqlalchemy/sql/compiler.py,sha256=aDD100xmz8WpBq8oe7PJ5ar8lk9emd54gEE5K2Hr76g,271187 -sqlalchemy/sql/crud.py,sha256=g9xcol2KRGjZi1qsb2-bVz8zgVy_53gfMtJcnNO2vyQ,56521 -sqlalchemy/sql/ddl.py,sha256=CIqMilCKfuQnF0lrZsQdTxgrbXqcTauKr0Ojzj77PFQ,45602 +sqlalchemy/sql/base.py,sha256=M1b-Tg49ikUW2mnZv0aI38oASG6dgeo4jBNWDgJgAg8,73925 +sqlalchemy/sql/cache_key.py,sha256=0Db8mR8IrpBgdzXs4TGTt98LOpL3c7KABd72MAPKUQQ,33668 +sqlalchemy/sql/coercions.py,sha256=hAEou9Ycyswzu8yz_Q7QkwL2_c3nctzBJQS2oDEr4iE,40664 +sqlalchemy/sql/compiler.py,sha256=hrTptbOKIgVIHapywj4Lk5OMwpXvHS-KGg3odFwlo-I,274687 +sqlalchemy/sql/crud.py,sha256=HBX4QPtW_PYYJmIKfNr-wE8IdEr963N24WXzFBUZOo0,56514 +sqlalchemy/sql/ddl.py,sha256=lKqvOigbcYrDG0euxd5F4tu9HbBi1kmp3eFPc45HH-8,45636 sqlalchemy/sql/default_comparator.py,sha256=utXWsZVGEjflhFfCT4ywa6RnhORc1Rryo87Hga71Rps,16707 sqlalchemy/sql/dml.py,sha256=pn0Lm1ofC5qVZzwGWFW73lPCiNba8OsTeemurJgwRyg,65614 -sqlalchemy/sql/elements.py,sha256=kGRUilpx-rr6TTZgZpC5b71OnxxmCgDJRF2fYjDtxh8,172025 +sqlalchemy/sql/elements.py,sha256=YfccXzQc9DlgF8q15kDf-zKBUY_vpIe0FGaVDBPoic4,176544 sqlalchemy/sql/events.py,sha256=iC_Q1Htm1Aobt5tOYxWfHHqNpoytrULORmUKcusH_-E,18290 sqlalchemy/sql/expression.py,sha256=VMX-dLpsZYnVRJpYNDozDUgaj7iQ0HuewUKVefD57PE,7586 -sqlalchemy/sql/functions.py,sha256=MjXK0IVv45Y4n96_TMDZGJ7fwAhGHPRbFP8hOJgaplQ,63689 -sqlalchemy/sql/lambdas.py,sha256=6P__bsWsFnrD7M18FPiBXI0L0OdWZOEV25FAijT4bwo,49289 +sqlalchemy/sql/functions.py,sha256=kMMYplvuIHFAPwxBI03SizwaLcYEHzysecWk-R1V-JM,63762 +sqlalchemy/sql/lambdas.py,sha256=DP0Qz7Ypo8QhzMwygGHYgRhwJMx-rNezO1euouH3iYU,49292 sqlalchemy/sql/naming.py,sha256=ZHs1qSV3ou8TYmZ92uvU3sfdklUQlIz4uhe330n05SU,6858 -sqlalchemy/sql/operators.py,sha256=r4oQp4h5zTMFFOpiFNV56joIK-QIjJCobatsmaZ-724,75935 +sqlalchemy/sql/operators.py,sha256=himArRqBzrljob3Zfhi_ZS-Jleg1u6YFp0g3d7Co6IM,76106 sqlalchemy/sql/roles.py,sha256=pOsVn_OZD7mF2gJByHf24Rjopt0_Hu3dUCEOK5t4KS8,7662 -sqlalchemy/sql/schema.py,sha256=WOIBaDVdg-zahrP95CPYgY4--3OQN56DH6xm28JDF-Y,228262 -sqlalchemy/sql/selectable.py,sha256=7lxe79hZvnHyzHe1DobodI1lZ1eo8quSLZ6phw10Zj4,232848 -sqlalchemy/sql/sqltypes.py,sha256=UV46KTkgxSin48oPckPOqk3Gx0tZT1l60qXwk7SbKlo,127101 -sqlalchemy/sql/traversals.py,sha256=NFgJrVJzInO3HrnG90CklxrDXhFydZohPs2vRJkh3Bo,33589 -sqlalchemy/sql/type_api.py,sha256=5DzdVquCJomFfpfMyLYbCb66PWxjxbSRdjh6UYB1Yv4,83841 +sqlalchemy/sql/schema.py,sha256=iFleWHkxi-3mKGiK_N1TzUqxnNwOpypB4bWDuAVQe8c,229717 +sqlalchemy/sql/selectable.py,sha256=cgyV0AsPy4CXAFdhMiTCkbgaHiFilW9sclzxlHJKH3o,236460 +sqlalchemy/sql/sqltypes.py,sha256=5_N9MhprQFWYc3yjcXgFC_DmvkQU-Jz-Ok9nIMYp2Q4,127469 +sqlalchemy/sql/traversals.py,sha256=3ScTC1fh1-y8Y478h_2Azmd2xdQdWPWkDve4YgrwMf8,33664 +sqlalchemy/sql/type_api.py,sha256=SN16_oNZG6G65cvG6ABPcptz_YV5vfB2fknwJZxrkOs,84464 sqlalchemy/sql/util.py,sha256=qGHQF-tPCj-m1FBerzT7weCanGcXU7dK5m-W7NHio-4,48077 sqlalchemy/sql/visitors.py,sha256=71wdVvhhZL4nJvVwFAs6ssaW-qZgNRSmKjpAcOzF_TA,36317 -sqlalchemy/testing/__init__.py,sha256=VsrEHrORpAF5n7Vfl43YQgABh6EP1xBx_gHxs7pSXeE,3126 +sqlalchemy/testing/__init__.py,sha256=zgitAYzsCWT_U48ZiifXHHLJFo8nZBYmI-5TueA4_lE,3160 sqlalchemy/testing/assertions.py,sha256=gL0rA7CCZJbcVgvWOPV91tTZTRwQc1_Ta0-ykBn83Ew,31439 sqlalchemy/testing/assertsql.py,sha256=IgQG7l94WaiRP8nTbilJh1ZHZl125g7GPq-S5kmQZN0,16817 -sqlalchemy/testing/asyncio.py,sha256=fkdRz-E37d5OrQKw5hdjmglOTJyXGnJzaJpvNXOBLxg,3728 +sqlalchemy/testing/asyncio.py,sha256=kM8uuOqDBagZF0r9xvGmsiirUVLUQ_KBzjUFU67W-b8,3830 sqlalchemy/testing/config.py,sha256=AqyH1qub_gDqX0BvlL-JBQe7N-t2wo8655FtwblUNOY,12090 -sqlalchemy/testing/engines.py,sha256=UnH-8--3zLlYz4IbbCPwC375Za_DC61Spz-oKulbs9Q,13347 +sqlalchemy/testing/engines.py,sha256=HFJceEBD3Q_TTFQMTtIV5wGWO_a7oUgoKtUF_z636SM,13481 sqlalchemy/testing/entities.py,sha256=IphFegPKbff3Un47jY6bi7_MQXy6qkx_50jX2tHZJR4,3354 sqlalchemy/testing/exclusions.py,sha256=T8B01hmm8WVs-EKcUOQRzabahPqblWJfOidi6bHJ6GA,12460 sqlalchemy/testing/fixtures/__init__.py,sha256=dMClrIoxqlYIFpk2ia4RZpkbfxsS_3EBigr9QsPJ66g,1198 sqlalchemy/testing/fixtures/base.py,sha256=9r_J2ksiTzClpUxW0TczICHrWR7Ny8PV8IsBz6TsGFI,12256 sqlalchemy/testing/fixtures/mypy.py,sha256=gdxiwNFIzDlNGSOdvM3gbwDceVCC9t8oM5kKbwyhGBk,11973 sqlalchemy/testing/fixtures/orm.py,sha256=8EFbnaBbXX_Bf4FcCzBUaAHgyVpsLGBHX16SGLqE3Fg,6095 -sqlalchemy/testing/fixtures/sql.py,sha256=MFOuYBUyPIpHJzjRCHL9vU-IT4bD6LXGGMvsp0v1FY8,15704 +sqlalchemy/testing/fixtures/sql.py,sha256=KZMjco9_3dsuspmkew5Ejp88Wlr9PsSBB1qeJGFxQAk,15900 sqlalchemy/testing/pickleable.py,sha256=U9mIqk-zaxq9Xfy7HErP7UrKgTov-A3QFnhZh-NiOjI,2833 sqlalchemy/testing/plugin/__init__.py,sha256=79F--BIY_NTBzVRIlJGgAY5LNJJ3cD19XvrAo4X0W9A,247 sqlalchemy/testing/plugin/bootstrap.py,sha256=oYScMbEW4pCnWlPEAq1insFruCXFQeEVBwo__i4McpU,1685 sqlalchemy/testing/plugin/plugin_base.py,sha256=BgNzWNEmgpK4CwhyblQQKnH-7FDKVi_Uul5vw8fFjBU,21578 -sqlalchemy/testing/plugin/pytestplugin.py,sha256=Jtj073ArTcAmetv81sHmrUhlH0SblcSK4wyN8S4hmvo,27554 +sqlalchemy/testing/plugin/pytestplugin.py,sha256=6jkQHH2VQMD75k2As9CuWXmEy9jrscoFRhCNg6-PaTw,27656 sqlalchemy/testing/profiling.py,sha256=PbuPhRFbauFilUONeY3tV_Y_5lBkD7iCa8VVyH2Sk9Y,10148 -sqlalchemy/testing/provision.py,sha256=zXsw2D2Xpmw_chmYLsE1GXQqKQ-so3V8xU_joTcKan0,14619 -sqlalchemy/testing/requirements.py,sha256=N9pSj7z2wVMkBif-DQfPVa_cl9k6p9g_J5FY1OsWtrY,51817 +sqlalchemy/testing/provision.py,sha256=3qFor_sN1FFlS7odUGkKqLUxGmQZC9XM67I9vQ_zeXo,14626 +sqlalchemy/testing/requirements.py,sha256=Z__o-1Rj9B7dI8E_l3qsKTvsg0rK198vB0A1p7A5dcM,52832 sqlalchemy/testing/schema.py,sha256=lr4GkGrGwagaHMuSGzWdzkMaj3HnS7dgfLLWfxt__-U,6513 sqlalchemy/testing/suite/__init__.py,sha256=Y5DRNG0Yl1u3ypt9zVF0Z9suPZeuO_UQGLl-wRgvTjU,722 sqlalchemy/testing/suite/test_cte.py,sha256=6zBC3W2OwX1Xs-HedzchcKN2S7EaLNkgkvV_JSZ_Pq0,6451 @@ -249,28 +248,28 @@ sqlalchemy/testing/suite/test_ddl.py,sha256=1Npkf0C_4UNxphthAGjG078n0vPEgnSIHpDu sqlalchemy/testing/suite/test_deprecations.py,sha256=BcJxZTcjYqeOAENVElCg3hVvU6fkGEW3KGBMfnW8bng,5337 sqlalchemy/testing/suite/test_dialect.py,sha256=EH4ZQWbnGdtjmx5amZtTyhYmrkXJCvW1SQoLahoE7uk,22923 sqlalchemy/testing/suite/test_insert.py,sha256=9azifj6-OCD7s8h_tAO1uPw100ibQv8YoKc_VA3hn3c,18824 -sqlalchemy/testing/suite/test_reflection.py,sha256=tJSbJFg5fw0sSUv3I_FPmhN7rWWeJtq3YyxmylWJUlM,106466 -sqlalchemy/testing/suite/test_results.py,sha256=NQ23m8FDVd0ub751jN4PswGoAhk5nrqvjHvpYULZXnc,15937 +sqlalchemy/testing/suite/test_reflection.py,sha256=7sML8-owubSQeEM7Ve6LbnB8uIVlNV00WWepKwII2a8,109648 +sqlalchemy/testing/suite/test_results.py,sha256=X720GafdA4p75SOGS93j-dXkt6QDEnnJbU2bh18VCcg,16914 sqlalchemy/testing/suite/test_rowcount.py,sha256=3KDTlRgjpQ1OVfp__1cv8Hvq4CsDKzmrhJQ_WIJWoJg,7900 -sqlalchemy/testing/suite/test_select.py,sha256=FvMFYQW9IJpDWGYZiJk46is6YrtmdSghBdTjZCG8T0Y,58574 +sqlalchemy/testing/suite/test_select.py,sha256=ulRZQJlzkwwcewEyisuBEXVWFR0Wshz9MEDxYYiYLwQ,61732 sqlalchemy/testing/suite/test_sequence.py,sha256=66bCoy4xo99GBSaX6Hxb88foANAykLGRz1YEKbvpfuA,9923 -sqlalchemy/testing/suite/test_types.py,sha256=rFmTOg6XuMch9L2-XthfLJRCTTwpZbMfrNss2g09gmc,65677 +sqlalchemy/testing/suite/test_types.py,sha256=K4MGHvnTtgqeksoQOBCZRVQYC7HoYO6Z6rVt5vj2t9o,67805 sqlalchemy/testing/suite/test_unicode_ddl.py,sha256=c3_eIxLyORuSOhNDP0jWKxPyUf3SwMFpdalxtquwqlM,6141 sqlalchemy/testing/suite/test_update_delete.py,sha256=yTiM2unnfOK9rK8ZkqeTTU_MkT-RsKFLmdYliniZfAY,3994 -sqlalchemy/testing/util.py,sha256=BFiSp3CEX95Dr-vv4l_7ZRu5vjZi9hjjnp-JKNfuS5E,14080 +sqlalchemy/testing/util.py,sha256=qldXKw8gRJ4I2x3uXsBssYMqwatmcMFMTOveRQCmfDU,14469 sqlalchemy/testing/warnings.py,sha256=fJ-QJUY2zY2PPxZJKv9medW-BKKbCNbA4Ns_V3YwFXM,1546 sqlalchemy/types.py,sha256=cQFM-hFRmaf1GErun1qqgEs6QxufvzMuwKqj9tuMPpE,3168 -sqlalchemy/util/__init__.py,sha256=B3bedg-LSQEscwqgmYYU-VENUX8_zAE3q9vb7tkfJNY,8277 -sqlalchemy/util/_collections.py,sha256=NE9dGJo8UNXIMbY3l3k8AO9BdPW04DlKTYraKCinchI,20063 -sqlalchemy/util/_concurrency_py3k.py,sha256=v8VVoBfFvFHe4j8mMkVLfdUrTbV897p8RWGAm73Ue9U,8574 +sqlalchemy/util/__init__.py,sha256=5D5Mquvx3SOmud0QErKzzGvBTkqMdhrrd_sXijOILeo,8312 +sqlalchemy/util/_collections.py,sha256=aZoSAVOXnHBoYEsxDOi0O9odg9wqLbGb7PGjaWQKiyY,20078 +sqlalchemy/util/_concurrency_py3k.py,sha256=zb0Bow2Y_QjTdaACEviBEEaFvqDuVvpJfmwCjaw8xNE,9170 sqlalchemy/util/_has_cy.py,sha256=wCQmeSjT3jaH_oxfCEtGk-1g0gbSpt5MCK5UcWdMWqk,1247 sqlalchemy/util/_py_collections.py,sha256=U6L5AoyLdgSv7cdqB4xxQbw1rpeJjyOZVXffgxgga8I,16714 -sqlalchemy/util/compat.py,sha256=R6bpBydldtbr6h7oJePihQxFb7jKiI-YDsK465MSOzk,8714 -sqlalchemy/util/concurrency.py,sha256=mhwHm0utriD14DRqxTBWgIW7QuwdSEiLgLiJdUjiR3w,2427 +sqlalchemy/util/compat.py,sha256=cnucBQOKspo58vjRpQXUBrHGguHOSFvftpD-I8vfUy0,8760 +sqlalchemy/util/concurrency.py,sha256=9lT_cMoO1fZNdY8QTUZ22oeSf-L5I-79Ke7chcBNPA0,3304 sqlalchemy/util/deprecations.py,sha256=YBwvvYhSB8LhasIZRKvg_-WNoVhPUcaYI1ZrnjDn868,11971 -sqlalchemy/util/langhelpers.py,sha256=khoFN05HjHiWY9ddeehCYxYG2u8LDzuiIKLOGLSAihU,64905 +sqlalchemy/util/langhelpers.py,sha256=uIK3szZuq9aMnO-vEpSlNekNWv4I-E391e56bkTnUm0,65090 sqlalchemy/util/preloaded.py,sha256=az7NmLJLsqs0mtM9uBkIu10-841RYDq8wOyqJ7xXvqE,5904 sqlalchemy/util/queue.py,sha256=CaeSEaYZ57YwtmLdNdOIjT5PK_LCuwMFiO0mpp39ybM,10185 sqlalchemy/util/tool_support.py,sha256=9braZyidaiNrZVsWtGmkSmus50-byhuYrlAqvhjcmnA,6135 sqlalchemy/util/topological.py,sha256=N3M3Le7KzGHCmqPGg0ZBqixTDGwmFLhOZvBtc4rHL_g,3458 -sqlalchemy/util/typing.py,sha256=FqH6WjV3p-8rz68YaXktpiZrPu3kmug2-gktJxBQSnI,16641 +sqlalchemy/util/typing.py,sha256=lFcGo1dJbZIZ9drAnvef-PzP0cX4LMxMSwgk3lJBb0g,18182 diff --git a/libs/PyYAML-6.0.1.dist-info/REQUESTED b/libs/SQLAlchemy-2.0.36.dist-info/REQUESTED similarity index 100% rename from libs/PyYAML-6.0.1.dist-info/REQUESTED rename to libs/SQLAlchemy-2.0.36.dist-info/REQUESTED diff --git a/libs/SQLAlchemy-2.0.27.dist-info/WHEEL b/libs/SQLAlchemy-2.0.36.dist-info/WHEEL similarity index 70% rename from libs/SQLAlchemy-2.0.27.dist-info/WHEEL rename to libs/SQLAlchemy-2.0.36.dist-info/WHEEL index 844cf17ca..be75fa70d 100644 --- a/libs/SQLAlchemy-2.0.27.dist-info/WHEEL +++ b/libs/SQLAlchemy-2.0.36.dist-info/WHEEL @@ -1,5 +1,5 @@ Wheel-Version: 1.0 -Generator: bdist_wheel (0.42.0) +Generator: setuptools (75.2.0) Root-Is-Purelib: false Tag: cp38-cp38-macosx_12_0_x86_64 diff --git a/libs/SQLAlchemy-2.0.27.dist-info/top_level.txt b/libs/SQLAlchemy-2.0.36.dist-info/top_level.txt similarity index 100% rename from libs/SQLAlchemy-2.0.27.dist-info/top_level.txt rename to libs/SQLAlchemy-2.0.36.dist-info/top_level.txt diff --git a/libs/alembic-1.13.1.dist-info/WHEEL b/libs/alembic-1.13.1.dist-info/WHEEL deleted file mode 100644 index 98c0d20b7..000000000 --- a/libs/alembic-1.13.1.dist-info/WHEEL +++ /dev/null @@ -1,5 +0,0 @@ -Wheel-Version: 1.0 -Generator: bdist_wheel (0.42.0) -Root-Is-Purelib: true -Tag: py3-none-any - diff --git a/libs/SQLAlchemy-2.0.27.dist-info/INSTALLER b/libs/alembic-1.14.0.dist-info/INSTALLER similarity index 100% rename from libs/SQLAlchemy-2.0.27.dist-info/INSTALLER rename to libs/alembic-1.14.0.dist-info/INSTALLER diff --git a/libs/alembic-1.13.1.dist-info/LICENSE b/libs/alembic-1.14.0.dist-info/LICENSE similarity index 95% rename from libs/alembic-1.13.1.dist-info/LICENSE rename to libs/alembic-1.14.0.dist-info/LICENSE index 74b9ce342..be8de0089 100644 --- a/libs/alembic-1.13.1.dist-info/LICENSE +++ b/libs/alembic-1.14.0.dist-info/LICENSE @@ -1,4 +1,4 @@ -Copyright 2009-2023 Michael Bayer. +Copyright 2009-2024 Michael Bayer. 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 @@ -16,4 +16,4 @@ 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. \ No newline at end of file +SOFTWARE. diff --git a/libs/alembic-1.13.1.dist-info/METADATA b/libs/alembic-1.14.0.dist-info/METADATA similarity index 99% rename from libs/alembic-1.13.1.dist-info/METADATA rename to libs/alembic-1.14.0.dist-info/METADATA index 7a6884d93..2f6963fc6 100644 --- a/libs/alembic-1.13.1.dist-info/METADATA +++ b/libs/alembic-1.14.0.dist-info/METADATA @@ -1,6 +1,6 @@ Metadata-Version: 2.1 Name: alembic -Version: 1.13.1 +Version: 1.14.0 Summary: A database migration tool for SQLAlchemy. Home-page: https://alembic.sqlalchemy.org Author: Mike Bayer diff --git a/libs/alembic-1.13.1.dist-info/RECORD b/libs/alembic-1.14.0.dist-info/RECORD similarity index 55% rename from libs/alembic-1.13.1.dist-info/RECORD rename to libs/alembic-1.14.0.dist-info/RECORD index f4dee8948..f7cc605d0 100644 --- a/libs/alembic-1.13.1.dist-info/RECORD +++ b/libs/alembic-1.14.0.dist-info/RECORD @@ -1,66 +1,66 @@ ../../bin/alembic,sha256=xqPGhIsDow0IG3BUa3a_VtCtKJgqxLpVJuFe1PQcGoA,236 -alembic-1.13.1.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4 -alembic-1.13.1.dist-info/LICENSE,sha256=soUmiob0QW6vTQWyrjiAwVb3xZqPk1pAK8BW6vszrwg,1058 -alembic-1.13.1.dist-info/METADATA,sha256=W1F2NBRkhqW55HiGmEIpdmiRt2skU5wwJd21UHFbSdQ,7390 -alembic-1.13.1.dist-info/RECORD,, -alembic-1.13.1.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 -alembic-1.13.1.dist-info/WHEEL,sha256=oiQVh_5PnQM0E3gPdiz09WCNmwiHDMaGer_elqB3coM,92 -alembic-1.13.1.dist-info/entry_points.txt,sha256=aykM30soxwGN0pB7etLc1q0cHJbL9dy46RnK9VX4LLw,48 -alembic-1.13.1.dist-info/top_level.txt,sha256=FwKWd5VsPFC8iQjpu1u9Cn-JnK3-V1RhUCmWqz1cl-s,8 -alembic/__init__.py,sha256=PMiQT_1tHyC_Od3GDBArBk7r14v8F62_xkzliPq9tLU,63 +alembic-1.14.0.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4 +alembic-1.14.0.dist-info/LICENSE,sha256=zhnnuit3ylhLgqZ5KFbhOOswsxHIlrB2wJpAXuRfvuk,1059 +alembic-1.14.0.dist-info/METADATA,sha256=5hNrxl9umF2WKbNL-MxyMUEZem8-OxRa49Qz9w7jqzo,7390 +alembic-1.14.0.dist-info/RECORD,, +alembic-1.14.0.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +alembic-1.14.0.dist-info/WHEEL,sha256=P9jw-gEje8ByB7_hXoICnHtVCrEwMQh-630tKvQWehc,91 +alembic-1.14.0.dist-info/entry_points.txt,sha256=aykM30soxwGN0pB7etLc1q0cHJbL9dy46RnK9VX4LLw,48 +alembic-1.14.0.dist-info/top_level.txt,sha256=FwKWd5VsPFC8iQjpu1u9Cn-JnK3-V1RhUCmWqz1cl-s,8 +alembic/__init__.py,sha256=qw_qYmTjOKiGcs--x0c6kjZo70tQTR5m8_lqF98Qr_0,63 alembic/__main__.py,sha256=373m7-TBh72JqrSMYviGrxCHZo-cnweM8AGF8A22PmY,78 alembic/autogenerate/__init__.py,sha256=ntmUTXhjLm4_zmqIwyVaECdpPDn6_u1yM9vYk6-553E,543 -alembic/autogenerate/api.py,sha256=Oc7MRtDhkSICsQ82fYP9bBMYaAjzzW2X_izM3AQU-OY,22171 -alembic/autogenerate/compare.py,sha256=3QLK2yCDW37bXbAIXcHGz4YOFlOW8bSfLHJ8bmzgG1M,44938 -alembic/autogenerate/render.py,sha256=uSbCpkh72mo00xGZ8CJa3teM_gqulCoNtxH0Ey8Ny1k,34939 +alembic/autogenerate/api.py,sha256=Bh-37G0PSFeT9WSfEQ-3TZoainXGLL2nsl4okv_xYc0,22173 +alembic/autogenerate/compare.py,sha256=cdUBH6qsedaJsnToSOu4MfcJaI4bjUJ4VWqtBlqsSr8,44944 +alembic/autogenerate/render.py,sha256=YB3C90rq7XDhjTia9GAnK6yfnVVzCROziZrbArmG9SE,35481 alembic/autogenerate/rewriter.py,sha256=uZWRkTYJoncoEJ5WY1QBRiozjyChqZDJPy4LtcRibjM,7846 -alembic/command.py,sha256=jWFNS-wPWA-Klfm0GsPfWh_8sPj4n7rKROJ0zrwhoR0,21712 -alembic/config.py,sha256=I12lm4V-AXSt-7nvub-Vtx5Zfa68pYP5xSrFQQd45rQ,22256 +alembic/command.py,sha256=2tkKrIoEgPfXrGgvMRGrUXH4l-7z466DOxd7Q2XOfL8,22169 +alembic/config.py,sha256=BZ7mwFRk2gq8GFNxxy9qvMUFx43YbDbQTC99OnjqiKY,22216 alembic/context.py,sha256=hK1AJOQXJ29Bhn276GYcosxeG7pC5aZRT5E8c4bMJ4Q,195 alembic/context.pyi,sha256=hUHbSnbSeEEMVkk0gDSXOq4_9edSjYzsjmmf-mL9Iao,31737 alembic/ddl/__init__.py,sha256=Df8fy4Vn_abP8B7q3x8gyFwEwnLw6hs2Ljt_bV3EZWE,152 -alembic/ddl/_autogen.py,sha256=0no9ywWP8gjvO57Ozc2naab4qNusVNn2fiJekjc275g,9179 -alembic/ddl/base.py,sha256=Jd7oPoAOGjOMcdMUIzSKnTjd8NKnTd7IjBXXyVpDCkU,9955 -alembic/ddl/impl.py,sha256=vkhkXFpLPJBG9jW2kv_sR5CC5czNd1ERLjLtfLuOFP0,28778 +alembic/ddl/_autogen.py,sha256=Blv2RrHNyF4cE6znCQXNXG5T9aO-YmiwD4Fz-qfoaWA,9275 +alembic/ddl/base.py,sha256=gazpvtk_6XURcsa0libwcaIquL5HwJDP1ZWKJ6P7x0I,9788 +alembic/ddl/impl.py,sha256=7-oxMb7KeycaK96x-kXw4mR6NSE1tmN0UEZIZrPcuhY,30195 alembic/ddl/mssql.py,sha256=ydvgBSaftKYjaBaMyqius66Ta4CICQSj79Og3Ed2atY,14219 -alembic/ddl/mysql.py,sha256=am221U_UK3wX33tNcXNiOObZV-Pa4CXuv7vN-epF9IU,16788 -alembic/ddl/oracle.py,sha256=TmoCq_FlbfyWAAk3e_q6mMQU0YmlfIcgKHpRfNMmgr0,6211 -alembic/ddl/postgresql.py,sha256=dcWLdDSqivzizVCce_H6RnOVAayPXDFfns-NC4-UaA8,29842 +alembic/ddl/mysql.py,sha256=kXOGYmpnL_9WL3ijXNsG4aAwy3m1HWJOoLZSePzmJF0,17316 +alembic/ddl/oracle.py,sha256=669YlkcZihlXFbnXhH2krdrvDry8q5pcUGfoqkg_R6Y,6243 +alembic/ddl/postgresql.py,sha256=GNCnx-N8UsCIstfW49J8ivYcKgRB8KFNPRgNtORC_AM,29883 alembic/ddl/sqlite.py,sha256=wLXhb8bJWRspKQTb-iVfepR4LXYgOuEbUWKX5qwDhIQ,7570 alembic/environment.py,sha256=MM5lPayGT04H3aeng1H7GQ8HEAs3VGX5yy6mDLCPLT4,43 alembic/migration.py,sha256=MV6Fju6rZtn2fTREKzXrCZM6aIBGII4OMZFix0X-GLs,41 alembic/op.py,sha256=flHtcsVqOD-ZgZKK2pv-CJ5Cwh-KJ7puMUNXzishxLw,167 -alembic/op.pyi,sha256=8R6SJr1dQharU0blmMJJj23XFO_hk9ZmAQBJBQOeXRU,49816 +alembic/op.pyi,sha256=QZ1ERetxIrpZNTyg48Btn5OJhhpMId-_MLMP36RauOw,50168 alembic/operations/__init__.py,sha256=e0KQSZAgLpTWvyvreB7DWg7RJV_MWSOPVDgCqsd2FzY,318 -alembic/operations/base.py,sha256=LCx4NH5NA2NLWQFaZTqE_p2KgLtqJ76oVcp1Grj-zFM,74004 +alembic/operations/base.py,sha256=JRaOtPqyqfaPjzGHxuP9VMcO1KsJNmbbLOvwG82qxGA,74474 alembic/operations/batch.py,sha256=YqtD4hJ3_RkFxvI7zbmBwxcLEyLHYyWQpsz4l5L85yI,26943 -alembic/operations/ops.py,sha256=2vtYFhYFDEVq158HwORfGTsobDM7c-t0lewuR7JKw7g,94353 -alembic/operations/schemaobj.py,sha256=vjoD57QvjbzzA-jyPIXulbOmb5_bGPtxoq58KsKI4Y0,9424 -alembic/operations/toimpl.py,sha256=SoNY2_gZX2baXTD-pNjpCWnON8D2QBSYQBIjo13-WKA,7115 +alembic/operations/ops.py,sha256=guIpLQzlqgkdP2LGDW8vWg_DXeAouEldiVZDgRas7YI,94953 +alembic/operations/schemaobj.py,sha256=Wp-bBe4a8lXPTvIHJttBY0ejtpVR5Jvtb2kI-U2PztQ,9468 +alembic/operations/toimpl.py,sha256=Fx-UKcq6S8pVtsEwPFjTKtEcAVKjfptn-BfpE1k3_ck,7517 alembic/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 alembic/runtime/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 -alembic/runtime/environment.py,sha256=9wSJaePNAXBXvirif_85ql7dSq4bXM1E6pSb2k-6uGI,41508 -alembic/runtime/migration.py,sha256=Yfv2fa11wiQ0WgoZaFldlWxCPq4dVDOCEOxST_-1VB0,50066 +alembic/runtime/environment.py,sha256=SkYB_am1h3FSG8IsExAQxGP_7WwzOVigqjlO747Aokc,41497 +alembic/runtime/migration.py,sha256=9GZ_bYZ6yMF7DUD1hgZdmB0YqvcdcNBBfxFaXKHeQoM,49857 alembic/script/__init__.py,sha256=lSj06O391Iy5avWAiq8SPs6N8RBgxkSPjP8wpXcNDGg,100 -alembic/script/base.py,sha256=4gkppn2FKCYDoBgzGkTslcwdyxSabmHvGq0uGKulwbI,37586 -alembic/script/revision.py,sha256=sfnXQw2UwiXs0E6gEPHBKWuSsB5KyuxZPTrFn__hIEk,62060 +alembic/script/base.py,sha256=XLNpdsLnBBSz4ZKMFUArFUdtL1HcjtuUDHNbA-5VlZA,37809 +alembic/script/revision.py,sha256=NTu-eu5Y78u4NoVXpT0alpD2oL40SGATA2sEMEf1el4,62306 alembic/script/write_hooks.py,sha256=NGB6NGgfdf7HK6XNNpSKqUCfzxazj-NRUePgFx7MJSM,5036 alembic/templates/async/README,sha256=ISVtAOvqvKk_5ThM5ioJE-lMkvf9IbknFUFVU_vPma4,58 -alembic/templates/async/alembic.ini.mako,sha256=uuhJETLWQuiYcs_jAOXHEjshEJ7VslEc1q4RRj0HWbE,3525 +alembic/templates/async/alembic.ini.mako,sha256=lw_6ie1tMbYGpbvE7MnzJvx101RbSTh9uu4t9cvDpug,3638 alembic/templates/async/env.py,sha256=zbOCf3Y7w2lg92hxSwmG1MM_7y56i_oRH4AKp0pQBYo,2389 alembic/templates/async/script.py.mako,sha256=MEqL-2qATlST9TAOeYgscMn1uy6HUS9NFvDgl93dMj8,635 alembic/templates/generic/README,sha256=MVlc9TYmr57RbhXET6QxgyCcwWP7w-vLkEsirENqiIQ,38 -alembic/templates/generic/alembic.ini.mako,sha256=sT7F852yN3c8X1-GKFlhuWExXxw9hY1eb1ZZ9flFSzc,3634 +alembic/templates/generic/alembic.ini.mako,sha256=YcwTOEoiZr663Gkt6twCjmaqZao0n6xjRl0B5prK79s,3746 alembic/templates/generic/env.py,sha256=TLRWOVW3Xpt_Tpf8JFzlnoPn_qoUu8UV77Y4o9XD6yI,2103 alembic/templates/generic/script.py.mako,sha256=MEqL-2qATlST9TAOeYgscMn1uy6HUS9NFvDgl93dMj8,635 alembic/templates/multidb/README,sha256=dWLDhnBgphA4Nzb7sNlMfCS3_06YqVbHhz-9O5JNqyI,606 -alembic/templates/multidb/alembic.ini.mako,sha256=mPh8JFJfWiGs6tMtL8_HAQ-Dz1QOoJgE5Vm76nIMqgU,3728 +alembic/templates/multidb/alembic.ini.mako,sha256=AW1OGb-QezxBY5mynSWW7b1lGKnh9sVPImfGgfXf2EM,3840 alembic/templates/multidb/env.py,sha256=6zNjnW8mXGUk7erTsAvrfhvqoczJ-gagjVq1Ypg2YIQ,4230 alembic/templates/multidb/script.py.mako,sha256=N06nMtNSwHkgl0EBXDyMt8njp9tlOesR583gfq21nbY,1090 alembic/testing/__init__.py,sha256=kOxOh5nwmui9d-_CCq9WA4Udwy7ITjm453w74CTLZDo,1159 -alembic/testing/assertions.py,sha256=1CbJk8c8-WO9eJ0XJ0jJvMsNRLUrXV41NOeIJUAlOBk,5015 -alembic/testing/env.py,sha256=zJacVb_z6uLs2U1TtkmnFH9P3_F-3IfYbVv4UEPOvfo,10754 -alembic/testing/fixtures.py,sha256=NyP4wE_dFN9ZzSGiBagRu1cdzkka03nwJYJYHYrrkSY,9112 +alembic/testing/assertions.py,sha256=ScUb1sVopIl70BirfHUJDvwswC70Q93CiIWwkiZbhHg,5207 +alembic/testing/env.py,sha256=giHWVLhHkfNWrPEfrAqhpMOLL6FgWoBCVAzBVrVbSSA,10766 +alembic/testing/fixtures.py,sha256=nBntOynOmVCFc7IYiN3DIQ3TBNTfiGCvL_1-FyCry8o,9462 alembic/testing/plugin/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 alembic/testing/plugin/bootstrap.py,sha256=9C6wtjGrIVztZ928w27hsQE0KcjDLIUtUN3dvZKsMVk,50 alembic/testing/requirements.py,sha256=dKeAO1l5TwBqXarJN-IPORlCqCJv-41Dj6oXoEikxHQ,5133 @@ -68,11 +68,11 @@ alembic/testing/schemacompare.py,sha256=N5UqSNCOJetIKC4vKhpYzQEpj08XkdgIoqBmEPQ3 alembic/testing/suite/__init__.py,sha256=MvE7-hwbaVN1q3NM-ztGxORU9dnIelUCINKqNxewn7Y,288 alembic/testing/suite/_autogen_fixtures.py,sha256=cDq1pmzHe15S6dZPGNC6sqFaCQ3hLT_oPV2IDigUGQ0,9880 alembic/testing/suite/test_autogen_comments.py,sha256=aEGqKUDw4kHjnDk298aoGcQvXJWmZXcIX_2FxH4cJK8,6283 -alembic/testing/suite/test_autogen_computed.py,sha256=qJeBpc8urnwTFvbwWrSTIbHVkRUuCXP-dKaNbUK2U2U,6077 +alembic/testing/suite/test_autogen_computed.py,sha256=CXAeF-5Wr2cmW8PB7ztHG_4ZQsn1gSWrHWfxi72grNU,6147 alembic/testing/suite/test_autogen_diffs.py,sha256=T4SR1n_kmcOKYhR4W1-dA0e5sddJ69DSVL2HW96kAkE,8394 alembic/testing/suite/test_autogen_fks.py,sha256=AqFmb26Buex167HYa9dZWOk8x-JlB1OK3bwcvvjDFaU,32927 alembic/testing/suite/test_autogen_identity.py,sha256=kcuqngG7qXAKPJDX4U8sRzPKHEJECHuZ0DtuaS6tVkk,5824 -alembic/testing/suite/test_environment.py,sha256=w9F0xnLEbALeR8k6_-Tz6JHvy91IqiTSypNasVzXfZQ,11877 +alembic/testing/suite/test_environment.py,sha256=OwD-kpESdLoc4byBrGrXbZHvqtPbzhFCG4W9hJOJXPQ,11877 alembic/testing/suite/test_op.py,sha256=2XQCdm_NmnPxHGuGj7hmxMzIhKxXNotUsKdACXzE1mM,1343 alembic/testing/util.py,sha256=CQrcQDA8fs_7ME85z5ydb-Bt70soIIID-qNY1vbR2dg,3350 alembic/testing/warnings.py,sha256=RxA7x_8GseANgw07Us8JN_1iGbANxaw6_VitX2ZGQH4,1078 @@ -80,7 +80,7 @@ alembic/util/__init__.py,sha256=KSZ7UT2YzH6CietgUtljVoE3QnGjoFKOi7RL5sgUxrk,1688 alembic/util/compat.py,sha256=RjHdQa1NomU3Zlvgfvza0OMiSRQSLRL3xVl3OdUy2UE,2594 alembic/util/editor.py,sha256=JIz6_BdgV8_oKtnheR6DZoB7qnrHrlRgWjx09AsTsUw,2546 alembic/util/exc.py,sha256=KQTru4zcgAmN4IxLMwLFS56XToUewaXB7oOLcPNjPwg,98 -alembic/util/langhelpers.py,sha256=KYyOjFjJ26evPmrwhdTvLQNXN0bK7AIy5sRdKD91Fvg,10038 -alembic/util/messaging.py,sha256=BM5OCZ6qmLftFRw5yPSxj539_QmfVwNYoU8qYsDqoJY,3132 +alembic/util/langhelpers.py,sha256=LpOcovnhMnP45kTt8zNJ4BHpyQrlF40OL6yDXjqKtsE,10026 +alembic/util/messaging.py,sha256=BxAHiJsYHBPb2m8zv4yaueSRAlVuYXWkRCeN02JXhqw,3250 alembic/util/pyfiles.py,sha256=zltVdcwEJJCPS2gHsQvkHkQakuF6wXiZ6zfwHbGNT0g,3489 -alembic/util/sqla_compat.py,sha256=toD1S63PgZ6iEteP9bwIf5E7DIUdQPo0UQ_Fn18qWnI,19536 +alembic/util/sqla_compat.py,sha256=XMfZaLdbVbJoniNUyI3RUUXu4gCWljjVBbJ7db6vCgc,19526 diff --git a/libs/SQLAlchemy-2.0.27.dist-info/REQUESTED b/libs/alembic-1.14.0.dist-info/REQUESTED similarity index 100% rename from libs/SQLAlchemy-2.0.27.dist-info/REQUESTED rename to libs/alembic-1.14.0.dist-info/REQUESTED diff --git a/libs/alembic-1.14.0.dist-info/WHEEL b/libs/alembic-1.14.0.dist-info/WHEEL new file mode 100644 index 000000000..9b78c4451 --- /dev/null +++ b/libs/alembic-1.14.0.dist-info/WHEEL @@ -0,0 +1,5 @@ +Wheel-Version: 1.0 +Generator: setuptools (75.3.0) +Root-Is-Purelib: true +Tag: py3-none-any + diff --git a/libs/alembic-1.13.1.dist-info/entry_points.txt b/libs/alembic-1.14.0.dist-info/entry_points.txt similarity index 100% rename from libs/alembic-1.13.1.dist-info/entry_points.txt rename to libs/alembic-1.14.0.dist-info/entry_points.txt diff --git a/libs/alembic-1.13.1.dist-info/top_level.txt b/libs/alembic-1.14.0.dist-info/top_level.txt similarity index 100% rename from libs/alembic-1.13.1.dist-info/top_level.txt rename to libs/alembic-1.14.0.dist-info/top_level.txt diff --git a/libs/alembic/__init__.py b/libs/alembic/__init__.py index c153c8aaf..637b2d4e1 100644 --- a/libs/alembic/__init__.py +++ b/libs/alembic/__init__.py @@ -1,4 +1,4 @@ from . import context from . import op -__version__ = "1.13.1" +__version__ = "1.14.0" diff --git a/libs/alembic/autogenerate/api.py b/libs/alembic/autogenerate/api.py index aa8f32f65..4c0391628 100644 --- a/libs/alembic/autogenerate/api.py +++ b/libs/alembic/autogenerate/api.py @@ -596,9 +596,9 @@ def _run_environment( migration_script = self.generated_revisions[-1] if not getattr(migration_script, "_needs_render", False): migration_script.upgrade_ops_list[-1].upgrade_token = upgrade_token - migration_script.downgrade_ops_list[ - -1 - ].downgrade_token = downgrade_token + migration_script.downgrade_ops_list[-1].downgrade_token = ( + downgrade_token + ) migration_script._needs_render = True else: migration_script._upgrade_ops.append( diff --git a/libs/alembic/autogenerate/compare.py b/libs/alembic/autogenerate/compare.py index fcef531a5..0d9851964 100644 --- a/libs/alembic/autogenerate/compare.py +++ b/libs/alembic/autogenerate/compare.py @@ -983,7 +983,7 @@ def _normalize_computed_default(sqltext: str) -> str: """ - return re.sub(r"[ \(\)'\"`\[\]]", "", sqltext).lower() + return re.sub(r"[ \(\)'\"`\[\]\t\r\n]", "", sqltext).lower() def _compare_computed_default( diff --git a/libs/alembic/autogenerate/render.py b/libs/alembic/autogenerate/render.py index 317a6dbed..38bdbfca2 100644 --- a/libs/alembic/autogenerate/render.py +++ b/libs/alembic/autogenerate/render.py @@ -187,9 +187,11 @@ def _render_create_table_comment( prefix=_alembic_autogenerate_prefix(autogen_context), tname=op.table_name, comment="%r" % op.comment if op.comment is not None else None, - existing="%r" % op.existing_comment - if op.existing_comment is not None - else None, + existing=( + "%r" % op.existing_comment + if op.existing_comment is not None + else None + ), schema="'%s'" % op.schema if op.schema is not None else None, indent=" ", ) @@ -216,9 +218,11 @@ def _render_drop_table_comment( return templ.format( prefix=_alembic_autogenerate_prefix(autogen_context), tname=op.table_name, - existing="%r" % op.existing_comment - if op.existing_comment is not None - else None, + existing=( + "%r" % op.existing_comment + if op.existing_comment is not None + else None + ), schema="'%s'" % op.schema if op.schema is not None else None, indent=" ", ) @@ -275,6 +279,9 @@ def _add_table(autogen_context: AutogenContext, op: ops.CreateTableOp) -> str: prefixes = ", ".join("'%s'" % p for p in table._prefixes) text += ",\nprefixes=[%s]" % prefixes + if op.if_not_exists is not None: + text += ",\nif_not_exists=%r" % bool(op.if_not_exists) + text += "\n)" return text @@ -287,6 +294,10 @@ def _drop_table(autogen_context: AutogenContext, op: ops.DropTableOp) -> str: } if op.schema: text += ", schema=%r" % _ident(op.schema) + + if op.if_exists is not None: + text += ", if_exists=%r" % bool(op.if_exists) + text += ")" return text @@ -320,6 +331,8 @@ def _add_index(autogen_context: AutogenContext, op: ops.CreateIndexOp) -> str: assert index.table is not None opts = _render_dialect_kwargs_items(autogen_context, index) + if op.if_not_exists is not None: + opts.append("if_not_exists=%r" % bool(op.if_not_exists)) text = tmpl % { "prefix": _alembic_autogenerate_prefix(autogen_context), "name": _render_gen_name(autogen_context, index.name), @@ -328,9 +341,11 @@ def _add_index(autogen_context: AutogenContext, op: ops.CreateIndexOp) -> str: _get_index_rendered_expressions(index, autogen_context) ), "unique": index.unique or False, - "schema": (", schema=%r" % _ident(index.table.schema)) - if index.table.schema - else "", + "schema": ( + (", schema=%r" % _ident(index.table.schema)) + if index.table.schema + else "" + ), "kwargs": ", " + ", ".join(opts) if opts else "", } return text @@ -350,6 +365,8 @@ def _drop_index(autogen_context: AutogenContext, op: ops.DropIndexOp) -> str: "table_name=%(table_name)r%(schema)s%(kwargs)s)" ) opts = _render_dialect_kwargs_items(autogen_context, index) + if op.if_exists is not None: + opts.append("if_exists=%r" % bool(op.if_exists)) text = tmpl % { "prefix": _alembic_autogenerate_prefix(autogen_context), "name": _render_gen_name(autogen_context, op.index_name), @@ -592,9 +609,11 @@ def _get_index_rendered_expressions( idx: Index, autogen_context: AutogenContext ) -> List[str]: return [ - repr(_ident(getattr(exp, "name", None))) - if isinstance(exp, sa_schema.Column) - else _render_potential_expr(exp, autogen_context, is_index=True) + ( + repr(_ident(getattr(exp, "name", None))) + if isinstance(exp, sa_schema.Column) + else _render_potential_expr(exp, autogen_context, is_index=True) + ) for exp in idx.expressions ] @@ -1075,9 +1094,11 @@ def _render_check_constraint( ) return "%(prefix)sCheckConstraint(%(sqltext)s%(opts)s)" % { "prefix": _sqlalchemy_autogenerate_prefix(autogen_context), - "opts": ", " + (", ".join("%s=%s" % (k, v) for k, v in opts)) - if opts - else "", + "opts": ( + ", " + (", ".join("%s=%s" % (k, v) for k, v in opts)) + if opts + else "" + ), "sqltext": _render_potential_expr( constraint.sqltext, autogen_context, wrap_in_text=False ), diff --git a/libs/alembic/command.py b/libs/alembic/command.py index 37aa6e67e..89c12354a 100644 --- a/libs/alembic/command.py +++ b/libs/alembic/command.py @@ -49,7 +49,7 @@ def init( :param config: a :class:`.Config` object. - :param directory: string path of the target directory + :param directory: string path of the target directory. :param template: string name of the migration environment template to use. @@ -174,7 +174,7 @@ def revision( will be applied to the structure generated by the revision process where it can be altered programmatically. Note that unlike all the other parameters, this option is only available via programmatic - use of :func:`.command.revision` + use of :func:`.command.revision`. """ @@ -315,9 +315,11 @@ def merge( :param config: a :class:`.Config` instance - :param message: string message to apply to the revision + :param revisions: The revisions to merge. - :param branch_label: string label name to apply to the new revision + :param message: string message to apply to the revision. + + :param branch_label: string label name to apply to the new revision. :param rev_id: hardcoded revision identifier instead of generating a new one. @@ -370,9 +372,10 @@ def upgrade( :param config: a :class:`.Config` instance. - :param revision: string revision target or range for --sql mode + :param revision: string revision target or range for --sql mode. May be + ``"heads"`` to target the most recent revision(s). - :param sql: if True, use ``--sql`` mode + :param sql: if True, use ``--sql`` mode. :param tag: an arbitrary "tag" that can be intercepted by custom ``env.py`` scripts via the :meth:`.EnvironmentContext.get_tag_argument` @@ -413,9 +416,10 @@ def downgrade( :param config: a :class:`.Config` instance. - :param revision: string revision target or range for --sql mode + :param revision: string revision target or range for --sql mode. May + be ``"base"`` to target the first revision. - :param sql: if True, use ``--sql`` mode + :param sql: if True, use ``--sql`` mode. :param tag: an arbitrary "tag" that can be intercepted by custom ``env.py`` scripts via the :meth:`.EnvironmentContext.get_tag_argument` @@ -449,12 +453,13 @@ def downgrade(rev, context): script.run_env() -def show(config, rev): +def show(config: Config, rev: str) -> None: """Show the revision(s) denoted by the given symbol. :param config: a :class:`.Config` instance. - :param revision: string revision target + :param rev: string revision target. May be ``"current"`` to show the + revision(s) currently applied in the database. """ @@ -484,7 +489,7 @@ def history( :param config: a :class:`.Config` instance. - :param rev_range: string revision range + :param rev_range: string revision range. :param verbose: output in verbose mode. @@ -543,7 +548,9 @@ def _display_current_history(rev, context): _display_history(config, script, base, head) -def heads(config, verbose=False, resolve_dependencies=False): +def heads( + config: Config, verbose: bool = False, resolve_dependencies: bool = False +) -> None: """Show current available heads in the script directory. :param config: a :class:`.Config` instance. @@ -568,7 +575,7 @@ def heads(config, verbose=False, resolve_dependencies=False): ) -def branches(config, verbose=False): +def branches(config: Config, verbose: bool = False) -> None: """Show current branch points. :param config: a :class:`.Config` instance. @@ -638,7 +645,9 @@ def stamp( :param config: a :class:`.Config` instance. :param revision: target revision or list of revisions. May be a list - to indicate stamping of multiple branch heads. + to indicate stamping of multiple branch heads; may be ``"base"`` + to remove all revisions from the table or ``"heads"`` to stamp the + most recent revision(s). .. note:: this parameter is called "revisions" in the command line interface. @@ -728,7 +737,7 @@ def ensure_version(config: Config, sql: bool = False) -> None: :param config: a :class:`.Config` instance. - :param sql: use ``--sql`` mode + :param sql: use ``--sql`` mode. .. versionadded:: 1.7.6 diff --git a/libs/alembic/config.py b/libs/alembic/config.py index 4b2263fdd..2c52e7cd1 100644 --- a/libs/alembic/config.py +++ b/libs/alembic/config.py @@ -221,8 +221,7 @@ def get_template_directory(self) -> str: @overload def get_section( self, name: str, default: None = ... - ) -> Optional[Dict[str, str]]: - ... + ) -> Optional[Dict[str, str]]: ... # "default" here could also be a TypeVar # _MT = TypeVar("_MT", bound=Mapping[str, str]), @@ -230,14 +229,12 @@ def get_section( @overload def get_section( self, name: str, default: Dict[str, str] - ) -> Dict[str, str]: - ... + ) -> Dict[str, str]: ... @overload def get_section( self, name: str, default: Mapping[str, str] - ) -> Union[Dict[str, str], Mapping[str, str]]: - ... + ) -> Union[Dict[str, str], Mapping[str, str]]: ... def get_section( self, name: str, default: Optional[Mapping[str, str]] = None @@ -313,14 +310,12 @@ def get_section_option( return default @overload - def get_main_option(self, name: str, default: str) -> str: - ... + def get_main_option(self, name: str, default: str) -> str: ... @overload def get_main_option( self, name: str, default: Optional[str] = None - ) -> Optional[str]: - ... + ) -> Optional[str]: ... def get_main_option( self, name: str, default: Optional[str] = None diff --git a/libs/alembic/ddl/_autogen.py b/libs/alembic/ddl/_autogen.py index e22153c49..74715b18a 100644 --- a/libs/alembic/ddl/_autogen.py +++ b/libs/alembic/ddl/_autogen.py @@ -287,18 +287,22 @@ def __init__( self.target_table, tuple(self.target_columns), ) + ( - (None if onupdate.lower() == "no action" else onupdate.lower()) - if onupdate - else None, - (None if ondelete.lower() == "no action" else ondelete.lower()) - if ondelete - else None, + ( + (None if onupdate.lower() == "no action" else onupdate.lower()) + if onupdate + else None + ), + ( + (None if ondelete.lower() == "no action" else ondelete.lower()) + if ondelete + else None + ), # convert initially + deferrable into one three-state value - "initially_deferrable" - if initially and initially.lower() == "deferred" - else "deferrable" - if deferrable - else "not deferrable", + ( + "initially_deferrable" + if initially and initially.lower() == "deferred" + else "deferrable" if deferrable else "not deferrable" + ), ) @util.memoized_property diff --git a/libs/alembic/ddl/base.py b/libs/alembic/ddl/base.py index 7a85a5c19..6fbe95245 100644 --- a/libs/alembic/ddl/base.py +++ b/libs/alembic/ddl/base.py @@ -40,7 +40,6 @@ class AlterTable(DDLElement): - """Represent an ALTER TABLE statement. Only the string name and optional schema name of the table @@ -176,7 +175,7 @@ def __init__( self.comment = comment -@compiles(RenameTable) # type: ignore[misc] +@compiles(RenameTable) def visit_rename_table( element: RenameTable, compiler: DDLCompiler, **kw ) -> str: @@ -186,7 +185,7 @@ def visit_rename_table( ) -@compiles(AddColumn) # type: ignore[misc] +@compiles(AddColumn) def visit_add_column(element: AddColumn, compiler: DDLCompiler, **kw) -> str: return "%s %s" % ( alter_table(compiler, element.table_name, element.schema), @@ -194,7 +193,7 @@ def visit_add_column(element: AddColumn, compiler: DDLCompiler, **kw) -> str: ) -@compiles(DropColumn) # type: ignore[misc] +@compiles(DropColumn) def visit_drop_column(element: DropColumn, compiler: DDLCompiler, **kw) -> str: return "%s %s" % ( alter_table(compiler, element.table_name, element.schema), @@ -202,7 +201,7 @@ def visit_drop_column(element: DropColumn, compiler: DDLCompiler, **kw) -> str: ) -@compiles(ColumnNullable) # type: ignore[misc] +@compiles(ColumnNullable) def visit_column_nullable( element: ColumnNullable, compiler: DDLCompiler, **kw ) -> str: @@ -213,7 +212,7 @@ def visit_column_nullable( ) -@compiles(ColumnType) # type: ignore[misc] +@compiles(ColumnType) def visit_column_type(element: ColumnType, compiler: DDLCompiler, **kw) -> str: return "%s %s %s" % ( alter_table(compiler, element.table_name, element.schema), @@ -222,7 +221,7 @@ def visit_column_type(element: ColumnType, compiler: DDLCompiler, **kw) -> str: ) -@compiles(ColumnName) # type: ignore[misc] +@compiles(ColumnName) def visit_column_name(element: ColumnName, compiler: DDLCompiler, **kw) -> str: return "%s RENAME %s TO %s" % ( alter_table(compiler, element.table_name, element.schema), @@ -231,20 +230,22 @@ def visit_column_name(element: ColumnName, compiler: DDLCompiler, **kw) -> str: ) -@compiles(ColumnDefault) # type: ignore[misc] +@compiles(ColumnDefault) def visit_column_default( element: ColumnDefault, compiler: DDLCompiler, **kw ) -> str: return "%s %s %s" % ( alter_table(compiler, element.table_name, element.schema), alter_column(compiler, element.column_name), - "SET DEFAULT %s" % format_server_default(compiler, element.default) - if element.default is not None - else "DROP DEFAULT", + ( + "SET DEFAULT %s" % format_server_default(compiler, element.default) + if element.default is not None + else "DROP DEFAULT" + ), ) -@compiles(ComputedColumnDefault) # type: ignore[misc] +@compiles(ComputedColumnDefault) def visit_computed_column( element: ComputedColumnDefault, compiler: DDLCompiler, **kw ): @@ -254,7 +255,7 @@ def visit_computed_column( ) -@compiles(IdentityColumnDefault) # type: ignore[misc] +@compiles(IdentityColumnDefault) def visit_identity_column( element: IdentityColumnDefault, compiler: DDLCompiler, **kw ): diff --git a/libs/alembic/ddl/impl.py b/libs/alembic/ddl/impl.py index 2e4f1ae94..2609a62de 100644 --- a/libs/alembic/ddl/impl.py +++ b/libs/alembic/ddl/impl.py @@ -21,7 +21,12 @@ from typing import Union from sqlalchemy import cast +from sqlalchemy import Column +from sqlalchemy import MetaData +from sqlalchemy import PrimaryKeyConstraint from sqlalchemy import schema +from sqlalchemy import String +from sqlalchemy import Table from sqlalchemy import text from . import _autogen @@ -43,11 +48,9 @@ from sqlalchemy.sql import Executable from sqlalchemy.sql.elements import ColumnElement from sqlalchemy.sql.elements import quoted_name - from sqlalchemy.sql.schema import Column from sqlalchemy.sql.schema import Constraint from sqlalchemy.sql.schema import ForeignKeyConstraint from sqlalchemy.sql.schema import Index - from sqlalchemy.sql.schema import Table from sqlalchemy.sql.schema import UniqueConstraint from sqlalchemy.sql.selectable import TableClause from sqlalchemy.sql.type_api import TypeEngine @@ -77,7 +80,6 @@ def __init__( class DefaultImpl(metaclass=ImplMeta): - """Provide the entrypoint for major migration operations, including database-specific behavioral variances. @@ -137,6 +139,40 @@ def static_output(self, text: str) -> None: self.output_buffer.write(text + "\n\n") self.output_buffer.flush() + def version_table_impl( + self, + *, + version_table: str, + version_table_schema: Optional[str], + version_table_pk: bool, + **kw: Any, + ) -> Table: + """Generate a :class:`.Table` object which will be used as the + structure for the Alembic version table. + + Third party dialects may override this hook to provide an alternate + structure for this :class:`.Table`; requirements are only that it + be named based on the ``version_table`` parameter and contains + at least a single string-holding column named ``version_num``. + + .. versionadded:: 1.14 + + """ + vt = Table( + version_table, + MetaData(), + Column("version_num", String(32), nullable=False), + schema=version_table_schema, + ) + if version_table_pk: + vt.append_constraint( + PrimaryKeyConstraint( + "version_num", name=f"{version_table}_pkc" + ) + ) + + return vt + def requires_recreate_in_batch( self, batch_op: BatchOperationsImpl ) -> bool: @@ -168,16 +204,15 @@ def bind(self) -> Optional[Connection]: def _exec( self, construct: Union[Executable, str], - execution_options: Optional[dict[str, Any]] = None, - multiparams: Sequence[dict] = (), - params: Dict[str, Any] = util.immutabledict(), + execution_options: Optional[Mapping[str, Any]] = None, + multiparams: Optional[Sequence[Mapping[str, Any]]] = None, + params: Mapping[str, Any] = util.immutabledict(), ) -> Optional[CursorResult]: if isinstance(construct, str): construct = text(construct) if self.as_sql: - if multiparams or params: - # TODO: coverage - raise Exception("Execution arguments not allowed with as_sql") + if multiparams is not None or params: + raise TypeError("SQL parameters not allowed with as_sql") compile_kw: dict[str, Any] if self.literal_binds and not isinstance( @@ -200,11 +235,16 @@ def _exec( assert conn is not None if execution_options: conn = conn.execution_options(**execution_options) - if params: - assert isinstance(multiparams, tuple) - multiparams += (params,) - return conn.execute(construct, multiparams) + if params and multiparams is not None: + raise TypeError( + "Can't send params and multiparams at the same time" + ) + + if multiparams: + return conn.execute(construct, multiparams) + else: + return conn.execute(construct, params) def execute( self, @@ -359,11 +399,11 @@ def rename_table( base.RenameTable(old_table_name, new_table_name, schema=schema) ) - def create_table(self, table: Table) -> None: + def create_table(self, table: Table, **kw: Any) -> None: table.dispatch.before_create( table, self.connection, checkfirst=False, _ddl_runner=self ) - self._exec(schema.CreateTable(table)) + self._exec(schema.CreateTable(table, **kw)) table.dispatch.after_create( table, self.connection, checkfirst=False, _ddl_runner=self ) @@ -382,11 +422,11 @@ def create_table(self, table: Table) -> None: if comment and with_comment: self.create_column_comment(column) - def drop_table(self, table: Table) -> None: + def drop_table(self, table: Table, **kw: Any) -> None: table.dispatch.before_drop( table, self.connection, checkfirst=False, _ddl_runner=self ) - self._exec(schema.DropTable(table)) + self._exec(schema.DropTable(table, **kw)) table.dispatch.after_drop( table, self.connection, checkfirst=False, _ddl_runner=self ) @@ -421,13 +461,15 @@ def bulk_insert( self._exec( sqla_compat._insert_inline(table).values( **{ - k: sqla_compat._literal_bindparam( - k, v, type_=table.c[k].type - ) - if not isinstance( - v, sqla_compat._literal_bindparam + k: ( + sqla_compat._literal_bindparam( + k, v, type_=table.c[k].type + ) + if not isinstance( + v, sqla_compat._literal_bindparam + ) + else v ) - else v for k, v in row.items() } ) diff --git a/libs/alembic/ddl/mysql.py b/libs/alembic/ddl/mysql.py index f312173e9..3482f672d 100644 --- a/libs/alembic/ddl/mysql.py +++ b/libs/alembic/ddl/mysql.py @@ -94,21 +94,29 @@ def alter_column( # type:ignore[override] column_name, schema=schema, newname=name if name is not None else column_name, - nullable=nullable - if nullable is not None - else existing_nullable - if existing_nullable is not None - else True, + nullable=( + nullable + if nullable is not None + else ( + existing_nullable + if existing_nullable is not None + else True + ) + ), type_=type_ if type_ is not None else existing_type, - default=server_default - if server_default is not False - else existing_server_default, - autoincrement=autoincrement - if autoincrement is not None - else existing_autoincrement, - comment=comment - if comment is not False - else existing_comment, + default=( + server_default + if server_default is not False + else existing_server_default + ), + autoincrement=( + autoincrement + if autoincrement is not None + else existing_autoincrement + ), + comment=( + comment if comment is not False else existing_comment + ), ) ) elif ( @@ -123,21 +131,29 @@ def alter_column( # type:ignore[override] column_name, schema=schema, newname=name if name is not None else column_name, - nullable=nullable - if nullable is not None - else existing_nullable - if existing_nullable is not None - else True, + nullable=( + nullable + if nullable is not None + else ( + existing_nullable + if existing_nullable is not None + else True + ) + ), type_=type_ if type_ is not None else existing_type, - default=server_default - if server_default is not False - else existing_server_default, - autoincrement=autoincrement - if autoincrement is not None - else existing_autoincrement, - comment=comment - if comment is not False - else existing_comment, + default=( + server_default + if server_default is not False + else existing_server_default + ), + autoincrement=( + autoincrement + if autoincrement is not None + else existing_autoincrement + ), + comment=( + comment if comment is not False else existing_comment + ), ) ) elif server_default is not False: @@ -368,9 +384,11 @@ def _mysql_alter_default( return "%s ALTER COLUMN %s %s" % ( alter_table(compiler, element.table_name, element.schema), format_column_name(compiler, element.column_name), - "SET DEFAULT %s" % format_server_default(compiler, element.default) - if element.default is not None - else "DROP DEFAULT", + ( + "SET DEFAULT %s" % format_server_default(compiler, element.default) + if element.default is not None + else "DROP DEFAULT" + ), ) diff --git a/libs/alembic/ddl/oracle.py b/libs/alembic/ddl/oracle.py index 540117407..eac99124f 100644 --- a/libs/alembic/ddl/oracle.py +++ b/libs/alembic/ddl/oracle.py @@ -141,9 +141,11 @@ def visit_column_default( return "%s %s %s" % ( alter_table(compiler, element.table_name, element.schema), alter_column(compiler, element.column_name), - "DEFAULT %s" % format_server_default(compiler, element.default) - if element.default is not None - else "DEFAULT NULL", + ( + "DEFAULT %s" % format_server_default(compiler, element.default) + if element.default is not None + else "DEFAULT NULL" + ), ) diff --git a/libs/alembic/ddl/postgresql.py b/libs/alembic/ddl/postgresql.py index 6507fcbdd..de64a4e05 100644 --- a/libs/alembic/ddl/postgresql.py +++ b/libs/alembic/ddl/postgresql.py @@ -218,7 +218,8 @@ def autogen_column_reflect(self, inspector, table, column_info): "join pg_class t on t.oid=d.refobjid " "join pg_attribute a on a.attrelid=t.oid and " "a.attnum=d.refobjsubid " - "where c.relkind='S' and c.relname=:seqname" + "where c.relkind='S' and " + "c.oid=cast(:seqname as regclass)" ), seqname=seq_match.group(1), ).first() diff --git a/libs/alembic/op.pyi b/libs/alembic/op.pyi index 83deac1eb..920444696 100644 --- a/libs/alembic/op.pyi +++ b/libs/alembic/op.pyi @@ -747,7 +747,12 @@ def create_primary_key( """ -def create_table(table_name: str, *columns: SchemaItem, **kw: Any) -> Table: +def create_table( + table_name: str, + *columns: SchemaItem, + if_not_exists: Optional[bool] = None, + **kw: Any, +) -> Table: r"""Issue a "create table" instruction using the current migration context. @@ -818,6 +823,10 @@ def create_table(table_name: str, *columns: SchemaItem, **kw: Any) -> Table: quoting of the schema outside of the default behavior, use the SQLAlchemy construct :class:`~sqlalchemy.sql.elements.quoted_name`. + :param if_not_exists: If True, adds IF NOT EXISTS operator when + creating the new table. + + .. versionadded:: 1.13.3 :param \**kw: Other keyword arguments are passed to the underlying :class:`sqlalchemy.schema.Table` object created for the command. @@ -998,7 +1007,11 @@ def drop_index( """ def drop_table( - table_name: str, *, schema: Optional[str] = None, **kw: Any + table_name: str, + *, + schema: Optional[str] = None, + if_exists: Optional[bool] = None, + **kw: Any, ) -> None: r"""Issue a "drop table" instruction using the current migration context. @@ -1013,6 +1026,10 @@ def drop_table( quoting of the schema outside of the default behavior, use the SQLAlchemy construct :class:`~sqlalchemy.sql.elements.quoted_name`. + :param if_exists: If True, adds IF EXISTS operator when + dropping the table. + + .. versionadded:: 1.13.3 :param \**kw: Other keyword arguments are passed to the underlying :class:`sqlalchemy.schema.Table` object created for the command. diff --git a/libs/alembic/operations/base.py b/libs/alembic/operations/base.py index bafe441a6..9b52fa6f2 100644 --- a/libs/alembic/operations/base.py +++ b/libs/alembic/operations/base.py @@ -406,8 +406,7 @@ def get_context(self) -> MigrationContext: return self.migration_context @overload - def invoke(self, operation: CreateTableOp) -> Table: - ... + def invoke(self, operation: CreateTableOp) -> Table: ... @overload def invoke( @@ -427,12 +426,10 @@ def invoke( DropTableOp, ExecuteSQLOp, ], - ) -> None: - ... + ) -> None: ... @overload - def invoke(self, operation: MigrateOperation) -> Any: - ... + def invoke(self, operation: MigrateOperation) -> Any: ... def invoke(self, operation: MigrateOperation) -> Any: """Given a :class:`.MigrateOperation`, invoke it in terms of @@ -1178,7 +1175,11 @@ def create_primary_key( ... def create_table( - self, table_name: str, *columns: SchemaItem, **kw: Any + self, + table_name: str, + *columns: SchemaItem, + if_not_exists: Optional[bool] = None, + **kw: Any, ) -> Table: r"""Issue a "create table" instruction using the current migration context. @@ -1250,6 +1251,10 @@ def create_table( quoting of the schema outside of the default behavior, use the SQLAlchemy construct :class:`~sqlalchemy.sql.elements.quoted_name`. + :param if_not_exists: If True, adds IF NOT EXISTS operator when + creating the new table. + + .. versionadded:: 1.13.3 :param \**kw: Other keyword arguments are passed to the underlying :class:`sqlalchemy.schema.Table` object created for the command. @@ -1441,7 +1446,12 @@ def drop_index( ... def drop_table( - self, table_name: str, *, schema: Optional[str] = None, **kw: Any + self, + table_name: str, + *, + schema: Optional[str] = None, + if_exists: Optional[bool] = None, + **kw: Any, ) -> None: r"""Issue a "drop table" instruction using the current migration context. @@ -1456,6 +1466,10 @@ def drop_table( quoting of the schema outside of the default behavior, use the SQLAlchemy construct :class:`~sqlalchemy.sql.elements.quoted_name`. + :param if_exists: If True, adds IF EXISTS operator when + dropping the table. + + .. versionadded:: 1.13.3 :param \**kw: Other keyword arguments are passed to the underlying :class:`sqlalchemy.schema.Table` object created for the command. @@ -1724,7 +1738,7 @@ def create_exclude_constraint( def create_foreign_key( self, - constraint_name: str, + constraint_name: Optional[str], referent_table: str, local_cols: List[str], remote_cols: List[str], @@ -1774,7 +1788,7 @@ def create_index( ... def create_primary_key( - self, constraint_name: str, columns: List[str] + self, constraint_name: Optional[str], columns: List[str] ) -> None: """Issue a "create primary key" instruction using the current batch migration context. diff --git a/libs/alembic/operations/ops.py b/libs/alembic/operations/ops.py index 7b65191cf..60b856a8f 100644 --- a/libs/alembic/operations/ops.py +++ b/libs/alembic/operations/ops.py @@ -349,7 +349,7 @@ def create_primary_key( def batch_create_primary_key( cls, operations: BatchOperations, - constraint_name: str, + constraint_name: Optional[str], columns: List[str], ) -> None: """Issue a "create primary key" instruction using the @@ -681,7 +681,7 @@ def create_foreign_key( def batch_create_foreign_key( cls, operations: BatchOperations, - constraint_name: str, + constraint_name: Optional[str], referent_table: str, local_cols: List[str], remote_cols: List[str], @@ -1159,6 +1159,7 @@ def __init__( columns: Sequence[SchemaItem], *, schema: Optional[str] = None, + if_not_exists: Optional[bool] = None, _namespace_metadata: Optional[MetaData] = None, _constraints_included: bool = False, **kw: Any, @@ -1166,6 +1167,7 @@ def __init__( self.table_name = table_name self.columns = columns self.schema = schema + self.if_not_exists = if_not_exists self.info = kw.pop("info", {}) self.comment = kw.pop("comment", None) self.prefixes = kw.pop("prefixes", None) @@ -1228,6 +1230,7 @@ def create_table( operations: Operations, table_name: str, *columns: SchemaItem, + if_not_exists: Optional[bool] = None, **kw: Any, ) -> Table: r"""Issue a "create table" instruction using the current migration @@ -1300,6 +1303,10 @@ def create_table( quoting of the schema outside of the default behavior, use the SQLAlchemy construct :class:`~sqlalchemy.sql.elements.quoted_name`. + :param if_not_exists: If True, adds IF NOT EXISTS operator when + creating the new table. + + .. versionadded:: 1.13.3 :param \**kw: Other keyword arguments are passed to the underlying :class:`sqlalchemy.schema.Table` object created for the command. @@ -1307,7 +1314,7 @@ def create_table( to the parameters given. """ - op = cls(table_name, columns, **kw) + op = cls(table_name, columns, if_not_exists=if_not_exists, **kw) return operations.invoke(op) @@ -1320,11 +1327,13 @@ def __init__( table_name: str, *, schema: Optional[str] = None, + if_exists: Optional[bool] = None, table_kw: Optional[MutableMapping[Any, Any]] = None, _reverse: Optional[CreateTableOp] = None, ) -> None: self.table_name = table_name self.schema = schema + self.if_exists = if_exists self.table_kw = table_kw or {} self.comment = self.table_kw.pop("comment", None) self.info = self.table_kw.pop("info", None) @@ -1371,9 +1380,9 @@ def to_table( info=self.info.copy() if self.info else {}, prefixes=list(self.prefixes) if self.prefixes else [], schema=self.schema, - _constraints_included=self._reverse._constraints_included - if self._reverse - else False, + _constraints_included=( + self._reverse._constraints_included if self._reverse else False + ), **self.table_kw, ) return t @@ -1385,6 +1394,7 @@ def drop_table( table_name: str, *, schema: Optional[str] = None, + if_exists: Optional[bool] = None, **kw: Any, ) -> None: r"""Issue a "drop table" instruction using the current @@ -1400,11 +1410,15 @@ def drop_table( quoting of the schema outside of the default behavior, use the SQLAlchemy construct :class:`~sqlalchemy.sql.elements.quoted_name`. + :param if_exists: If True, adds IF EXISTS operator when + dropping the table. + + .. versionadded:: 1.13.3 :param \**kw: Other keyword arguments are passed to the underlying :class:`sqlalchemy.schema.Table` object created for the command. """ - op = cls(table_name, schema=schema, table_kw=kw) + op = cls(table_name, schema=schema, if_exists=if_exists, table_kw=kw) operations.invoke(op) diff --git a/libs/alembic/operations/schemaobj.py b/libs/alembic/operations/schemaobj.py index 32b26e9b9..59c1002f1 100644 --- a/libs/alembic/operations/schemaobj.py +++ b/libs/alembic/operations/schemaobj.py @@ -223,10 +223,12 @@ def table(self, name: str, *columns, **kw) -> Table: t = sa_schema.Table(name, m, *cols, **kw) constraints = [ - sqla_compat._copy(elem, target_table=t) - if getattr(elem, "parent", None) is not t - and getattr(elem, "parent", None) is not None - else elem + ( + sqla_compat._copy(elem, target_table=t) + if getattr(elem, "parent", None) is not t + and getattr(elem, "parent", None) is not None + else elem + ) for elem in columns if isinstance(elem, (Constraint, Index)) ] diff --git a/libs/alembic/operations/toimpl.py b/libs/alembic/operations/toimpl.py index 4759f7fd2..4b960049c 100644 --- a/libs/alembic/operations/toimpl.py +++ b/libs/alembic/operations/toimpl.py @@ -79,8 +79,14 @@ def _count_constraint(constraint): @Operations.implementation_for(ops.DropTableOp) def drop_table(operations: "Operations", operation: "ops.DropTableOp") -> None: + kw = {} + if operation.if_exists is not None: + if not sqla_14: + raise NotImplementedError("SQLAlchemy 1.4+ required") + + kw["if_exists"] = operation.if_exists operations.impl.drop_table( - operation.to_table(operations.migration_context) + operation.to_table(operations.migration_context), **kw ) @@ -127,8 +133,14 @@ def drop_index(operations: "Operations", operation: "ops.DropIndexOp") -> None: def create_table( operations: "Operations", operation: "ops.CreateTableOp" ) -> "Table": + kw = {} + if operation.if_not_exists is not None: + if not sqla_14: + raise NotImplementedError("SQLAlchemy 1.4+ required") + + kw["if_not_exists"] = operation.if_not_exists table = operation.to_table(operations.migration_context) - operations.impl.create_table(table) + operations.impl.create_table(table, **kw) return table diff --git a/libs/alembic/runtime/environment.py b/libs/alembic/runtime/environment.py index d64b2adc2..a30972ec9 100644 --- a/libs/alembic/runtime/environment.py +++ b/libs/alembic/runtime/environment.py @@ -108,7 +108,6 @@ class EnvironmentContext(util.ModuleClsProxy): - """A configurational facade made available in an ``env.py`` script. The :class:`.EnvironmentContext` acts as a *facade* to the more @@ -342,18 +341,17 @@ def get_tag_argument(self) -> Optional[str]: return self.context_opts.get("tag", None) # type: ignore[no-any-return] # noqa: E501 @overload - def get_x_argument(self, as_dictionary: Literal[False]) -> List[str]: - ... + def get_x_argument(self, as_dictionary: Literal[False]) -> List[str]: ... @overload - def get_x_argument(self, as_dictionary: Literal[True]) -> Dict[str, str]: - ... + def get_x_argument( + self, as_dictionary: Literal[True] + ) -> Dict[str, str]: ... @overload def get_x_argument( self, as_dictionary: bool = ... - ) -> Union[List[str], Dict[str, str]]: - ... + ) -> Union[List[str], Dict[str, str]]: ... def get_x_argument( self, as_dictionary: bool = False diff --git a/libs/alembic/runtime/migration.py b/libs/alembic/runtime/migration.py index 95c69bc69..28f01c3b3 100644 --- a/libs/alembic/runtime/migration.py +++ b/libs/alembic/runtime/migration.py @@ -24,10 +24,6 @@ from sqlalchemy import Column from sqlalchemy import literal_column -from sqlalchemy import MetaData -from sqlalchemy import PrimaryKeyConstraint -from sqlalchemy import String -from sqlalchemy import Table from sqlalchemy.engine import Engine from sqlalchemy.engine import url as sqla_url from sqlalchemy.engine.strategies import MockEngineStrategy @@ -36,6 +32,7 @@ from .. import util from ..util import sqla_compat from ..util.compat import EncodedIO +from ..util.sqla_compat import _select if TYPE_CHECKING: from sqlalchemy.engine import Dialect @@ -86,7 +83,6 @@ def __exit__(self, type_: Any, value: Any, traceback: Any) -> None: class MigrationContext: - """Represent the database state made available to a migration script. @@ -191,18 +187,6 @@ def __init__( self.version_table_schema = version_table_schema = opts.get( "version_table_schema", None ) - self._version = Table( - version_table, - MetaData(), - Column("version_num", String(32), nullable=False), - schema=version_table_schema, - ) - if opts.get("version_table_pk", True): - self._version.append_constraint( - PrimaryKeyConstraint( - "version_num", name="%s_pkc" % version_table - ) - ) self._start_from_rev: Optional[str] = opts.get("starting_rev") self.impl = ddl.DefaultImpl.get_by_dialect(dialect)( @@ -213,14 +197,23 @@ def __init__( self.output_buffer, opts, ) + + self._version = self.impl.version_table_impl( + version_table=version_table, + version_table_schema=version_table_schema, + version_table_pk=opts.get("version_table_pk", True), + ) + log.info("Context impl %s.", self.impl.__class__.__name__) if self.as_sql: log.info("Generating static SQL") log.info( "Will assume %s DDL.", - "transactional" - if self.impl.transactional_ddl - else "non-transactional", + ( + "transactional" + if self.impl.transactional_ddl + else "non-transactional" + ), ) @classmethod @@ -345,9 +338,9 @@ def upgrade(): # except that it will not know it's in "autocommit" and will # emit deprecation warnings when an autocommit action takes # place. - self.connection = ( - self.impl.connection - ) = base_connection.execution_options(isolation_level="AUTOCOMMIT") + self.connection = self.impl.connection = ( + base_connection.execution_options(isolation_level="AUTOCOMMIT") + ) # sqlalchemy future mode will "autobegin" in any case, so take # control of that "transaction" here @@ -539,7 +532,10 @@ def get_current_heads(self) -> Tuple[str, ...]: return () assert self.connection is not None return tuple( - row[0] for row in self.connection.execute(self._version.select()) + row[0] + for row in self.connection.execute( + _select(self._version.c.version_num) + ) ) def _ensure_version_table(self, purge: bool = False) -> None: @@ -1006,8 +1002,7 @@ class MigrationStep: if TYPE_CHECKING: @property - def doc(self) -> Optional[str]: - ... + def doc(self) -> Optional[str]: ... @property def name(self) -> str: diff --git a/libs/alembic/script/base.py b/libs/alembic/script/base.py index 5945ca591..30df6ddb2 100644 --- a/libs/alembic/script/base.py +++ b/libs/alembic/script/base.py @@ -56,7 +56,6 @@ class ScriptDirectory: - """Provides operations upon an Alembic script directory. This object is useful to get information as to current revisions, @@ -188,6 +187,7 @@ def from_config(cls, config: Config) -> ScriptDirectory: split_on_path = { None: None, "space": " ", + "newline": "\n", "os": os.pathsep, ":": ":", ";": ";", @@ -201,7 +201,8 @@ def from_config(cls, config: Config) -> ScriptDirectory: raise ValueError( "'%s' is not a valid value for " "version_path_separator; " - "expected 'space', 'os', ':', ';'" % version_path_separator + "expected 'space', 'newline', 'os', ':', ';'" + % version_path_separator ) from ke else: if split_char is None: @@ -211,7 +212,9 @@ def from_config(cls, config: Config) -> ScriptDirectory: ) else: version_locations = [ - x for x in version_locations_str.split(split_char) if x + x.strip() + for x in version_locations_str.split(split_char) + if x ] else: version_locations = None @@ -610,7 +613,7 @@ def _generate_create_date(self) -> datetime.datetime: if self.timezone is not None: if ZoneInfo is None: raise util.CommandError( - "Python >= 3.9 is required for timezone support or" + "Python >= 3.9 is required for timezone support or " "the 'backports.zoneinfo' package must be installed." ) # First, assume correct capitalization @@ -732,9 +735,11 @@ def generate_revision( if depends_on: with self._catch_revision_errors(): resolved_depends_on = [ - dep - if dep in rev.branch_labels # maintain branch labels - else rev.revision # resolve partial revision identifiers + ( + dep + if dep in rev.branch_labels # maintain branch labels + else rev.revision + ) # resolve partial revision identifiers for rev, dep in [ (not_none(self.revision_map.get_revision(dep)), dep) for dep in util.to_list(depends_on) @@ -808,7 +813,6 @@ def _rev_path( class Script(revision.Revision): - """Represent a single revision file in a ``versions/`` directory. The :class:`.Script` instance is returned by methods @@ -930,9 +934,11 @@ def _head_only( if head_indicators or tree_indicators: text += "%s%s%s" % ( " (head)" if self._is_real_head else "", - " (effective head)" - if self.is_head and not self._is_real_head - else "", + ( + " (effective head)" + if self.is_head and not self._is_real_head + else "" + ), " (current)" if self._db_current_indicator else "", ) if tree_indicators: diff --git a/libs/alembic/script/revision.py b/libs/alembic/script/revision.py index 77a802cdc..c3108e985 100644 --- a/libs/alembic/script/revision.py +++ b/libs/alembic/script/revision.py @@ -56,8 +56,7 @@ def __call__( inclusive: bool, implicit_base: bool, assert_relative_length: bool, - ) -> Tuple[Set[Revision], Tuple[Optional[_RevisionOrBase], ...]]: - ... + ) -> Tuple[Set[Revision], Tuple[Optional[_RevisionOrBase], ...]]: ... class RevisionError(Exception): @@ -720,9 +719,11 @@ def _shares_lineage( resolved_target = target resolved_test_against_revs = [ - self._revision_for_ident(test_against_rev) - if not isinstance(test_against_rev, Revision) - else test_against_rev + ( + self._revision_for_ident(test_against_rev) + if not isinstance(test_against_rev, Revision) + else test_against_rev + ) for test_against_rev in util.to_tuple( test_against_revs, default=() ) @@ -1016,9 +1017,9 @@ def get_ancestors(rev_id: str) -> Set[str]: # each time but it was getting complicated current_heads[current_candidate_idx] = heads_to_add[0] current_heads.extend(heads_to_add[1:]) - ancestors_by_idx[ - current_candidate_idx - ] = get_ancestors(heads_to_add[0]) + ancestors_by_idx[current_candidate_idx] = ( + get_ancestors(heads_to_add[0]) + ) ancestors_by_idx.extend( get_ancestors(head) for head in heads_to_add[1:] ) @@ -1183,9 +1184,13 @@ def _parse_downgrade_target( branch_label = symbol # Walk down the tree to find downgrade target. rev = self._walk( - start=self.get_revision(symbol) - if branch_label is None - else self.get_revision("%s@%s" % (branch_label, symbol)), + start=( + self.get_revision(symbol) + if branch_label is None + else self.get_revision( + "%s@%s" % (branch_label, symbol) + ) + ), steps=rel_int, no_overwalk=assert_relative_length, ) @@ -1303,9 +1308,13 @@ def _parse_upgrade_target( ) return ( self._walk( - start=self.get_revision(symbol) - if branch_label is None - else self.get_revision("%s@%s" % (branch_label, symbol)), + start=( + self.get_revision(symbol) + if branch_label is None + else self.get_revision( + "%s@%s" % (branch_label, symbol) + ) + ), steps=relative, no_overwalk=assert_relative_length, ), @@ -1694,15 +1703,13 @@ def is_merge_point(self) -> bool: @overload -def tuple_rev_as_scalar(rev: None) -> None: - ... +def tuple_rev_as_scalar(rev: None) -> None: ... @overload def tuple_rev_as_scalar( rev: Union[Tuple[_T, ...], List[_T]] -) -> Union[_T, Tuple[_T, ...], List[_T]]: - ... +) -> Union[_T, Tuple[_T, ...], List[_T]]: ... def tuple_rev_as_scalar( diff --git a/libs/alembic/templates/async/alembic.ini.mako b/libs/alembic/templates/async/alembic.ini.mako index 0e5f43fde..7eee91320 100644 --- a/libs/alembic/templates/async/alembic.ini.mako +++ b/libs/alembic/templates/async/alembic.ini.mako @@ -1,7 +1,8 @@ # A generic, single database configuration. [alembic] -# path to migration scripts +# path to migration scripts. +# Use forward slashes (/) also on windows to provide an os agnostic path script_location = ${script_location} # template used to generate migration file names; The default value is %%(rev)s_%%(slug)s @@ -20,8 +21,7 @@ prepend_sys_path = . # leave blank for localtime # timezone = -# max length of characters to apply to the -# "slug" field +# max length of characters to apply to the "slug" field # truncate_slug_length = 40 # set to 'true' to run the environment during @@ -47,6 +47,7 @@ prepend_sys_path = . # version_path_separator = : # version_path_separator = ; # version_path_separator = space +# version_path_separator = newline version_path_separator = os # Use os.pathsep. Default configuration used for new projects. # set to 'true' to search source files recursively @@ -89,12 +90,12 @@ keys = console keys = generic [logger_root] -level = WARN +level = WARNING handlers = console qualname = [logger_sqlalchemy] -level = WARN +level = WARNING handlers = qualname = sqlalchemy.engine diff --git a/libs/alembic/templates/generic/alembic.ini.mako b/libs/alembic/templates/generic/alembic.ini.mako index 29245dd3f..f1f76cae8 100644 --- a/libs/alembic/templates/generic/alembic.ini.mako +++ b/libs/alembic/templates/generic/alembic.ini.mako @@ -2,6 +2,7 @@ [alembic] # path to migration scripts +# Use forward slashes (/) also on windows to provide an os agnostic path script_location = ${script_location} # template used to generate migration file names; The default value is %%(rev)s_%%(slug)s @@ -22,8 +23,7 @@ prepend_sys_path = . # leave blank for localtime # timezone = -# max length of characters to apply to the -# "slug" field +# max length of characters to apply to the "slug" field # truncate_slug_length = 40 # set to 'true' to run the environment during @@ -49,6 +49,7 @@ prepend_sys_path = . # version_path_separator = : # version_path_separator = ; # version_path_separator = space +# version_path_separator = newline version_path_separator = os # Use os.pathsep. Default configuration used for new projects. # set to 'true' to search source files recursively @@ -91,12 +92,12 @@ keys = console keys = generic [logger_root] -level = WARN +level = WARNING handlers = console qualname = [logger_sqlalchemy] -level = WARN +level = WARNING handlers = qualname = sqlalchemy.engine diff --git a/libs/alembic/templates/multidb/alembic.ini.mako b/libs/alembic/templates/multidb/alembic.ini.mako index c7fbe4822..bf383ea1d 100644 --- a/libs/alembic/templates/multidb/alembic.ini.mako +++ b/libs/alembic/templates/multidb/alembic.ini.mako @@ -2,6 +2,7 @@ [alembic] # path to migration scripts +# Use forward slashes (/) also on windows to provide an os agnostic path script_location = ${script_location} # template used to generate migration file names; The default value is %%(rev)s_%%(slug)s @@ -22,8 +23,7 @@ prepend_sys_path = . # leave blank for localtime # timezone = -# max length of characters to apply to the -# "slug" field +# max length of characters to apply to the "slug" field # truncate_slug_length = 40 # set to 'true' to run the environment during @@ -49,6 +49,7 @@ prepend_sys_path = . # version_path_separator = : # version_path_separator = ; # version_path_separator = space +# version_path_separator = newline version_path_separator = os # Use os.pathsep. Default configuration used for new projects. # set to 'true' to search source files recursively @@ -96,12 +97,12 @@ keys = console keys = generic [logger_root] -level = WARN +level = WARNING handlers = console qualname = [logger_sqlalchemy] -level = WARN +level = WARNING handlers = qualname = sqlalchemy.engine diff --git a/libs/alembic/testing/assertions.py b/libs/alembic/testing/assertions.py index ec9593b71..e071697cd 100644 --- a/libs/alembic/testing/assertions.py +++ b/libs/alembic/testing/assertions.py @@ -74,7 +74,9 @@ class _ErrorContainer: @contextlib.contextmanager -def _expect_raises(except_cls, msg=None, check_context=False): +def _expect_raises( + except_cls, msg=None, check_context=False, text_exact=False +): ec = _ErrorContainer() if check_context: are_we_already_in_a_traceback = sys.exc_info()[0] @@ -85,7 +87,10 @@ def _expect_raises(except_cls, msg=None, check_context=False): ec.error = err success = True if msg is not None: - assert re.search(msg, str(err), re.UNICODE), f"{msg} !~ {err}" + if text_exact: + assert str(err) == msg, f"{msg} != {err}" + else: + assert re.search(msg, str(err), re.UNICODE), f"{msg} !~ {err}" if check_context and not are_we_already_in_a_traceback: _assert_proper_exception_context(err) print(str(err).encode("utf-8")) @@ -98,8 +103,12 @@ def expect_raises(except_cls, check_context=True): return _expect_raises(except_cls, check_context=check_context) -def expect_raises_message(except_cls, msg, check_context=True): - return _expect_raises(except_cls, msg=msg, check_context=check_context) +def expect_raises_message( + except_cls, msg, check_context=True, text_exact=False +): + return _expect_raises( + except_cls, msg=msg, check_context=check_context, text_exact=text_exact + ) def eq_ignore_whitespace(a, b, msg=None): diff --git a/libs/alembic/testing/env.py b/libs/alembic/testing/env.py index 5df7ef822..c37b4d303 100644 --- a/libs/alembic/testing/env.py +++ b/libs/alembic/testing/env.py @@ -118,7 +118,7 @@ def _sqlite_testing_config(sourceless=False, future=False): keys = console [logger_root] -level = WARN +level = WARNING handlers = console qualname = @@ -171,7 +171,7 @@ def _multi_dir_testing_config(sourceless=False, extra_version_location=""): keys = console [logger_root] -level = WARN +level = WARNING handlers = console qualname = @@ -216,7 +216,7 @@ def _no_sql_testing_config(dialect="postgresql", directives=""): keys = console [logger_root] -level = WARN +level = WARNING handlers = console qualname = @@ -497,7 +497,7 @@ def _multidb_testing_config(engines): keys = console [logger_root] -level = WARN +level = WARNING handlers = console qualname = diff --git a/libs/alembic/testing/fixtures.py b/libs/alembic/testing/fixtures.py index 4b83a745f..3b5ce596e 100644 --- a/libs/alembic/testing/fixtures.py +++ b/libs/alembic/testing/fixtures.py @@ -49,6 +49,12 @@ def migration_context(self, connection): connection, opts=dict(transaction_per_migration=True) ) + @testing.fixture + def as_sql_migration_context(self, connection): + return MigrationContext.configure( + connection, opts=dict(transaction_per_migration=True, as_sql=True) + ) + @testing.fixture def connection(self): with config.db.connect() as conn: @@ -268,9 +274,11 @@ def _run_alter_col(self, from_, to_, compare=None): "x", column.name, existing_type=column.type, - existing_server_default=column.server_default - if column.server_default is not None - else False, + existing_server_default=( + column.server_default + if column.server_default is not None + else False + ), existing_nullable=True if column.nullable else False, # existing_comment=column.comment, nullable=to_.get("nullable", None), @@ -298,9 +306,13 @@ def _run_alter_col(self, from_, to_, compare=None): new_col["type"], new_col.get("default", None), compare.get("type", old_col["type"]), - compare["server_default"].text - if "server_default" in compare - else column.server_default.arg.text - if column.server_default is not None - else None, + ( + compare["server_default"].text + if "server_default" in compare + else ( + column.server_default.arg.text + if column.server_default is not None + else None + ) + ), ) diff --git a/libs/alembic/testing/suite/test_autogen_computed.py b/libs/alembic/testing/suite/test_autogen_computed.py index 01a89a1fe..04a3caf07 100644 --- a/libs/alembic/testing/suite/test_autogen_computed.py +++ b/libs/alembic/testing/suite/test_autogen_computed.py @@ -124,6 +124,7 @@ def test_cant_change_computed_warning(self, test_case): lambda: (None, None), lambda: (sa.Computed("5"), sa.Computed("5")), lambda: (sa.Computed("bar*5"), sa.Computed("bar*5")), + lambda: (sa.Computed("bar*5"), sa.Computed("bar * \r\n\t5")), ( lambda: (sa.Computed("bar*5"), None), config.requirements.computed_doesnt_reflect_as_server_default, diff --git a/libs/alembic/testing/suite/test_environment.py b/libs/alembic/testing/suite/test_environment.py index 8c86859ae..df2d9afbd 100644 --- a/libs/alembic/testing/suite/test_environment.py +++ b/libs/alembic/testing/suite/test_environment.py @@ -24,9 +24,9 @@ def _fixture(self, opts): self.context = MigrationContext.configure( dialect=conn.dialect, opts=opts ) - self.context.output_buffer = ( - self.context.impl.output_buffer - ) = io.StringIO() + self.context.output_buffer = self.context.impl.output_buffer = ( + io.StringIO() + ) else: self.context = MigrationContext.configure( connection=conn, opts=opts diff --git a/libs/alembic/util/langhelpers.py b/libs/alembic/util/langhelpers.py index 4a5bf09a9..80d88cbce 100644 --- a/libs/alembic/util/langhelpers.py +++ b/libs/alembic/util/langhelpers.py @@ -234,20 +234,17 @@ def rev_id() -> str: @overload -def to_tuple(x: Any, default: Tuple[Any, ...]) -> Tuple[Any, ...]: - ... +def to_tuple(x: Any, default: Tuple[Any, ...]) -> Tuple[Any, ...]: ... @overload -def to_tuple(x: None, default: Optional[_T] = ...) -> _T: - ... +def to_tuple(x: None, default: Optional[_T] = ...) -> _T: ... @overload def to_tuple( x: Any, default: Optional[Tuple[Any, ...]] = None -) -> Tuple[Any, ...]: - ... +) -> Tuple[Any, ...]: ... def to_tuple( diff --git a/libs/alembic/util/messaging.py b/libs/alembic/util/messaging.py index 5f14d5975..6618fa7fa 100644 --- a/libs/alembic/util/messaging.py +++ b/libs/alembic/util/messaging.py @@ -95,11 +95,17 @@ def msg( write_outstream(sys.stdout, "\n") else: # left indent output lines - lines = textwrap.wrap(msg, TERMWIDTH) + indent = " " + lines = textwrap.wrap( + msg, + TERMWIDTH, + initial_indent=indent, + subsequent_indent=indent, + ) if len(lines) > 1: for line in lines[0:-1]: - write_outstream(sys.stdout, " ", line, "\n") - write_outstream(sys.stdout, " ", lines[-1], ("\n" if newline else "")) + write_outstream(sys.stdout, line, "\n") + write_outstream(sys.stdout, lines[-1], ("\n" if newline else "")) if flush: sys.stdout.flush() diff --git a/libs/alembic/util/sqla_compat.py b/libs/alembic/util/sqla_compat.py index 8489c19fa..d4ed0fdd5 100644 --- a/libs/alembic/util/sqla_compat.py +++ b/libs/alembic/util/sqla_compat.py @@ -59,8 +59,7 @@ class _CompilerProtocol(Protocol): - def __call__(self, element: Any, compiler: Any, **kw: Any) -> str: - ... + def __call__(self, element: Any, compiler: Any, **kw: Any) -> str: ... def _safe_int(value: str) -> Union[int, str]: @@ -95,8 +94,7 @@ class _Unsupported: def compiles( element: Type[ClauseElement], *dialects: str - ) -> Callable[[_CompilerProtocol], _CompilerProtocol]: - ... + ) -> Callable[[_CompilerProtocol], _CompilerProtocol]: ... else: from sqlalchemy.ext.compiler import compiles @@ -529,7 +527,7 @@ def __init__(self, table: Table, text: TextClause) -> None: self.fake_column = schema.Column(self.text.text, sqltypes.NULLTYPE) table.append_column(self.fake_column) - def get_children(self): + def get_children(self, **kw): return [self.fake_column] diff --git a/libs/apprise-1.7.6.dist-info/RECORD b/libs/apprise-1.7.6.dist-info/RECORD deleted file mode 100644 index 250648105..000000000 --- a/libs/apprise-1.7.6.dist-info/RECORD +++ /dev/null @@ -1,183 +0,0 @@ -../../bin/apprise,sha256=ZJ-e4qqxNLtdW_DAvpuPPX5iROIiQd8I6nvg7vtAv-g,233 -apprise-1.7.6.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4 -apprise-1.7.6.dist-info/LICENSE,sha256=gt7qKBxRhVcdmXCYVtrWP6DtYjD0DzONet600dkU994,1343 -apprise-1.7.6.dist-info/METADATA,sha256=z_gaX2IdNJqw4T9q7AYQri9jcIs-OTGCo3t2EgEY-mw,44823 -apprise-1.7.6.dist-info/RECORD,, -apprise-1.7.6.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 -apprise-1.7.6.dist-info/WHEEL,sha256=Xo9-1PvkuimrydujYJAjF7pCkriuXBpUPEjma1nZyJ0,92 -apprise-1.7.6.dist-info/entry_points.txt,sha256=71YypBuNdjAKiaLsiMG40HEfLHxkU4Mi7o_S0s0d8wI,45 -apprise-1.7.6.dist-info/top_level.txt,sha256=JrCRn-_rXw5LMKXkIgMSE4E0t1Ks9TYrBH54Pflwjkk,8 -apprise/Apprise.py,sha256=Stm2NhJprWRaMwQfTiIQG_nR1bLpHi_zcdwEcsCpa-A,32865 -apprise/Apprise.pyi,sha256=_4TBKvT-QVj3s6PuTh3YX-BbQMeJTdBGdVpubLMY4_k,2203 -apprise/AppriseAsset.py,sha256=jRW8Y1EcAvjVA9h_mINmsjO4DM3S0aDl6INIFVMcUCs,11647 -apprise/AppriseAsset.pyi,sha256=NYLXXYbScgRkspP27XGpRRM_uliPu1OCdWdZBPPvLng,979 -apprise/AppriseAttachment.py,sha256=vhrktSrp8GLr32aK4KqV6BX83IpI1lxZe-pGo1wiSFM,12540 -apprise/AppriseAttachment.pyi,sha256=R9-0dVqWpeaFrVpcREwPhGy3qHWztG5jEjYIOsbE5dM,1145 -apprise/AppriseConfig.py,sha256=wfuR6Mb3ZLHvjvqWdFp9lVmjjDRWs65unY9qa92RkCg,16909 -apprise/AppriseConfig.pyi,sha256=_mUlCnncqAq8sL01WxQTgZjnb2ic9kZXvtqZmVl-fc8,1568 -apprise/AppriseLocale.py,sha256=4uSr4Nj_rz6ISMMAfRVRk58wZVLKOofJgk2x0_E8NkQ,8994 -apprise/AttachmentManager.py,sha256=EwlnjuKn3fv_pioWcmMCkyDTsO178t6vkEOD8AjAPsw,2053 -apprise/ConfigurationManager.py,sha256=MUmGajxjgnr6FGN7xb3q0nD0VVgdTdvapBBR7CsI-rc,2058 -apprise/NotificationManager.py,sha256=ZJgkiCgcJ7Bz_6bwQ47flrcxvLMbA4Vbw0HG_yTsGdE,2041 -apprise/URLBase.py,sha256=xRP0-blocp9UudYh04Hb3fIEmTZWJaTv_tzjrqaB9fg,29423 -apprise/URLBase.pyi,sha256=WLaRREH7FzZ5x3-qkDkupojWGFC4uFwJ1EDt02lVs8c,520 -apprise/__init__.py,sha256=ArtvoarAMnBcSfXF7L_hzq5CUJ9TUnHopiC7xafCe3c,3368 -apprise/assets/NotifyXML-1.0.xsd,sha256=292qQ_IUl5EWDhPyzm9UTT0C2rVvJkyGar8jiODkJs8,986 -apprise/assets/NotifyXML-1.1.xsd,sha256=bjR3CGG4AEXoJjYkGCbDttKHSkPP1FlIWO02E7G59g4,1758 -apprise/assets/themes/default/apprise-failure-128x128.ico,sha256=Mt0ptfHJaN3Wsv5UCNDn9_3lyEDHxVDv1JdaDEI_xCA,67646 -apprise/assets/themes/default/apprise-failure-128x128.png,sha256=66ps8TDPxVH3g9PlObJqF-0x952CjnqQyN3zvpRcOT8,16135 -apprise/assets/themes/default/apprise-failure-256x256.png,sha256=bQBsKKCsKfR9EqgYOZrcVcVa5y8qG58PN2mEqO5eNRI,41931 -apprise/assets/themes/default/apprise-failure-32x32.png,sha256=vH0pZffIDCvkejpr3fJHGXW__8Yc3R_p0bacX6t6l18,2437 -apprise/assets/themes/default/apprise-failure-72x72.png,sha256=EP5A8DHRDr9srgupFSwOoyQ308bNJ8aL192J_L4K-ec,7600 -apprise/assets/themes/default/apprise-info-128x128.ico,sha256=F5_CirmXueRCRI5Z_Crf6TS6jVIXTJlRD83zw1oJ66g,67646 -apprise/assets/themes/default/apprise-info-128x128.png,sha256=bBqRZAgQey-gkmJrnFhPbzjILSrljE59mRkgj3raMQo,16671 -apprise/assets/themes/default/apprise-info-256x256.png,sha256=B5r_O4d9MHCmSWZwfbqQgZSp-ZetTdiBSwKcMTF1aFA,43331 -apprise/assets/themes/default/apprise-info-32x32.png,sha256=lt3NZ95TzkiCNVNlurrB2fE2nriMa1wftl7nrNXmb6c,2485 -apprise/assets/themes/default/apprise-info-72x72.png,sha256=kDnsZpqNUZGqs9t1ECUup7FOfXUIL-rupnQCYJp9So4,7875 -apprise/assets/themes/default/apprise-logo.png,sha256=85ttALudKkLmiqilJT7mUQLUXRFmM1AK89rnwLm313s,160907 -apprise/assets/themes/default/apprise-success-128x128.ico,sha256=uCopPwdQjxgfohKazHaDzYs9y4oiaOpL048PYC6WRlg,67646 -apprise/assets/themes/default/apprise-success-128x128.png,sha256=nvDuU_QqhGlw6cMtdj7Mv-gPgqCEx-0DaaXn1KBLVYg,17446 -apprise/assets/themes/default/apprise-success-256x256.png,sha256=vXfKuxY3n0eeXHKdb9hTxICxOEn7HjAQ4IZpX0HSLzc,48729 -apprise/assets/themes/default/apprise-success-32x32.png,sha256=Jg9pFJh3YPI-LiPBebyJ7Z4Vt7BRecaE8AsRjQVIkME,2471 -apprise/assets/themes/default/apprise-success-72x72.png,sha256=FQbgvIhqKOhEK0yvrhaSpai0R7hrkTt_-GaC2KUgCCk,7858 -apprise/assets/themes/default/apprise-warning-128x128.ico,sha256=6XaQPOx0oWK_xbhr4Yhb7qNazCWwSs9lk2SYR2MHTrQ,67646 -apprise/assets/themes/default/apprise-warning-128x128.png,sha256=pf5c4Ph7jWH7gf39dJoieSj8TzAsY3TXI-sGISGVIW4,16784 -apprise/assets/themes/default/apprise-warning-256x256.png,sha256=SY-xlaiXaj420iEYKC2_fJxU-yj2SuaQg6xfPNi83bw,43708 -apprise/assets/themes/default/apprise-warning-32x32.png,sha256=97R2ywNvcwczhBoWEIgajVtWjgT8fLs4FCCz4wu0dwc,2472 -apprise/assets/themes/default/apprise-warning-72x72.png,sha256=L8moEInkO_OLxoOcuvN7rmrGZo64iJeH20o-24MQghE,7913 -apprise/attachment/AttachBase.py,sha256=T3WreGrTsqqGplXJO36jm-N14X7ymSc9xt7XdTYuXVE,13656 -apprise/attachment/AttachBase.pyi,sha256=w0XG_QKauiMLJ7eQ4S57IiLIURZHm_Snw7l6-ih9GP8,961 -apprise/attachment/AttachFile.py,sha256=MbHY_av0GeM_AIBKV02Hq7SHiZ9eCr1yTfvDMUgi2I4,4765 -apprise/attachment/AttachHTTP.py,sha256=_CMPp4QGLATfGO2-Nw57sxsQyed9z3ywgoB0vpK3KZk,13779 -apprise/attachment/__init__.py,sha256=xabgXpvV05X-YRuqIt3uGYMXwYNXjHyF6Dwd8HfZCFE,1658 -apprise/cli.py,sha256=h-pWSQPqQficH6J-OEp3MTGydWyt6vMYnDZvHCeAt4Y,20697 -apprise/common.py,sha256=I6wfrndggCL7l7KAl7Cm4uwAX9n0l3SN4-BVvTE0L0M,5593 -apprise/common.pyi,sha256=luF3QRiClDCk8Z23rI6FCGYsVmodOt_JYfYyzGogdNM,447 -apprise/config/ConfigBase.py,sha256=d1efIuQFCJr66WgpudV2DWtxY3-tuZAyMAhHXBzJ8p0,53194 -apprise/config/ConfigBase.pyi,sha256=cngfobwH6v2vxYbQrObDi5Z-t5wcquWF-wR0kBCr3Eg,54 -apprise/config/ConfigFile.py,sha256=u_SDaN3OHMyaAq2X7k_T4_PRKkVsDwleqBz9YIN5lbA,6138 -apprise/config/ConfigHTTP.py,sha256=Iy6Ji8_nX3xDjFgJGLrz4ftrMlMiyKiFGzYGJ7rMSMQ,9457 -apprise/config/ConfigMemory.py,sha256=epEAgNy-eJVWoQaUOvjivMWxXTofy6wAQ-NbCqYmuyE,2829 -apprise/config/__init__.py,sha256=lbsxrUpB1IYM2q7kjYhsXQGgPF-yZXJrKFE361tdIPY,1663 -apprise/conversion.py,sha256=0VZ0eCZfksN-97Vl0TjVjwnCTgus3XTRioceSFnP-gc,6277 -apprise/decorators/CustomNotifyPlugin.py,sha256=i4D-sgOsBWsxO5auWCN2bgXLLPuADaaLlJ1gUKLj2bU,7972 -apprise/decorators/__init__.py,sha256=e_PDAm0kQNzwDPx-NJZLPfLMd2VAABvNZtxx_iDviRM,1487 -apprise/decorators/notify.py,sha256=a2WupErNw1_SMAld7jPC273bskiChMpYy95BOog5A9w,5111 -apprise/emojis.py,sha256=ONF0t8dY9f2XlEkLUG79-ybKVAj2GqbPj2-Be97vAoI,87738 -apprise/i18n/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 -apprise/i18n/en/LC_MESSAGES/apprise.mo,sha256=oUTuHREmLEYN07oqYqRMJ_kU71-o5o37NsF4RXlC5AU,3959 -apprise/logger.py,sha256=131hqhed8cUj9x_mfXDEvwA2YbcYDFAYiWVK1HgxRVY,6921 -apprise/manager.py,sha256=R9w8jxQRNy6Z_XDcobkt4JYbrC4jtj2OwRw9Zrib3CA,26857 -apprise/plugins/NotifyAppriseAPI.py,sha256=ISBE0brD3eQdyw3XrGXd4Uc4kSYvIuI3SSUVCt-bkdo,16654 -apprise/plugins/NotifyAprs.py,sha256=xdL_aIVgb4ggxRFeCdkZAbgHYZ8DWLw9pRpLZQ0rHoE,25523 -apprise/plugins/NotifyBark.py,sha256=bsDvKooRy4k1Gg7tvBjv3DIx7-WZiV_mbTrkTwMtd9Q,15698 -apprise/plugins/NotifyBase.py,sha256=G3xkF_a2BWqNSxsrnOW7NUgHjOqBCYC5zihCifWemo8,30360 -apprise/plugins/NotifyBase.pyi,sha256=aKlZXRYUgG8lz_ZgGkYYJ_GKhuf18youTmMU-FlG7z8,21 -apprise/plugins/NotifyBoxcar.py,sha256=vR00-WggHa1nHYWyb-f5P2V-G4f683fU_-GBlIeJvD0,12867 -apprise/plugins/NotifyBulkSMS.py,sha256=stPWAFCfhBP617zYK9Dgk6pNJBN_WcyJtODzo0jR1QQ,16005 -apprise/plugins/NotifyBulkVS.py,sha256=viLGeyUDiirRRM7CgRqqElHSLYFnMugDtWE6Ytjqfaw,13290 -apprise/plugins/NotifyBurstSMS.py,sha256=cN2kRETKIK5LhwpQEA8C68LKv8KEUPmXYe-nTSegGls,15550 -apprise/plugins/NotifyChantify.py,sha256=GJJOAtSnVoIfKbJF_W1DTu7WsvS_zHdjO4T1XTKT87g,6673 -apprise/plugins/NotifyClickSend.py,sha256=UfOJqsas6WLjQskojuJE7I_-lrb5QrkMiBZv-po_Q9c,11229 -apprise/plugins/NotifyD7Networks.py,sha256=4E6Fh0kQoDlMMwgZJDOXky7c7KrdMMvqprcfm29scWU,15043 -apprise/plugins/NotifyDBus.py,sha256=1eVJHIL3XkFjDePMqfcll35Ie1vxggJ1iBsVFAIaF00,14379 -apprise/plugins/NotifyDapnet.py,sha256=KuXjBU0ZrIYtoDei85NeLZ-IP810T4w5oFXH9sWiSh0,13624 -apprise/plugins/NotifyDingTalk.py,sha256=NJyETgN6QjtRqtxQjfBLFVuFpURyWykRftm6WpQJVbY,12009 -apprise/plugins/NotifyDiscord.py,sha256=M_qmTzB7NNL5_agjYDX38KBN1jRzDBp2EMSNwEF_9Tw,26072 -apprise/plugins/NotifyEmail.py,sha256=Y_ZOrdK6hTUKHLvogKpV5VqD8byzDyDSvwIVmfdsC2g,39789 -apprise/plugins/NotifyEmby.py,sha256=OMVO8XsVl_XCBYNNNQi8ni2lS4voLfU8Puk1xJOAvHs,24039 -apprise/plugins/NotifyEnigma2.py,sha256=Hj0Q9YOeljSwbfiuMKLqXTVX_1g_mjNUGEts7wfrwno,11498 -apprise/plugins/NotifyFCM/__init__.py,sha256=mBFtIgIJuLIFnMB5ndx5Makjs9orVMc2oLoD7LaVT48,21669 -apprise/plugins/NotifyFCM/color.py,sha256=8iqDtadloQh2TMxkFmIFwenHqKp1pHHn1bwyWOzZ6TY,4592 -apprise/plugins/NotifyFCM/common.py,sha256=978uBUoNdtopCtylipGiKQdsQ8FTONxkFBp7uJMZHc8,1718 -apprise/plugins/NotifyFCM/oauth.py,sha256=Vvbd0-rd5BPIjAneG3rILU153JIzfSZ0kaDov6hm96M,11197 -apprise/plugins/NotifyFCM/priority.py,sha256=0WuRW1y1HVnybgjlTeCZPHzt7j8SwWnC7faNcjioAOc,8163 -apprise/plugins/NotifyFeishu.py,sha256=IpcABdLZJ1vcQdZHlmASVbNOiOCIrmgKFhz1hbdskY4,7266 -apprise/plugins/NotifyFlock.py,sha256=0rUIa9nToGsO8BTUgixh8Z_qdVixJeH479UNYjcE4EM,12748 -apprise/plugins/NotifyForm.py,sha256=38nL-2m1cf4gEQFQ4NpvA4j9i5_nNUgelReWFSjyV5U,17905 -apprise/plugins/NotifyFreeMobile.py,sha256=XCkgZLc3KKGlx_9UdeoMJVcHpeQrOml9T93S-DGf4bs,6644 -apprise/plugins/NotifyGnome.py,sha256=8MXTa8gZg1wTgNJfLlmq7_fl3WaYK-SX6VR91u308C4,9059 -apprise/plugins/NotifyGoogleChat.py,sha256=lnoN17m6lZANaXcElDTP8lcuVWjIZEK8C6_iqJNAnw4,12622 -apprise/plugins/NotifyGotify.py,sha256=DNlOIHyuYitO5use9oa_REPm2Fant7y9QSaatrZFNI0,10551 -apprise/plugins/NotifyGrowl.py,sha256=M6ViUz967VhEHtXrE7lbCKF3aB4pIXNEzJLjjGAmvhM,14023 -apprise/plugins/NotifyGuilded.py,sha256=eCMCoFFuE0XNY8HlLM21zoxgBNgqEKQ8dwYj8LihfRU,3641 -apprise/plugins/NotifyHomeAssistant.py,sha256=zqWu7TtdXhTbGNuflC8WfydbHsCLiEBw4uBUcF7YZtw,10739 -apprise/plugins/NotifyHttpSMS.py,sha256=pDEUHCCB18IhOgDcVK3_FFDJdAcrdTIfPzj0jNnZZBo,11136 -apprise/plugins/NotifyIFTTT.py,sha256=oMvTQ0bEu2eJQgw9BwxAwTNOtbZ_ER-zleJvWpWTj7w,13425 -apprise/plugins/NotifyJSON.py,sha256=70ctjmArGzuvM1gHNt1bCiQVWE7Fp9vd2nWhSXwFvw0,13851 -apprise/plugins/NotifyJoin.py,sha256=B8FHp7cblZBkxTgfrka6mNnf6oQVBXVuGISgSau00z0,13581 -apprise/plugins/NotifyKavenegar.py,sha256=F5xTUdebM1lK6yGFbZJQB9Zgw2LTI0angeA-3Nu-89w,12620 -apprise/plugins/NotifyKumulos.py,sha256=eCEW2ZverZqETOLHVWMC4E8Ll6rEhhEWOSD73RD80SM,8214 -apprise/plugins/NotifyLametric.py,sha256=h8vZoX-Ll5NBZRprBlxTO2H9w0lOiMxglGvUgJtK4_8,37534 -apprise/plugins/NotifyLine.py,sha256=OVI0ozMJcq_-dI8dodVX52dzUzgENlAbOik-Kw4l-rI,10676 -apprise/plugins/NotifyLunaSea.py,sha256=woN8XdkwAjhgxAXp7Zj4XsWLybNL80l4W3Dx5BvobZg,14459 -apprise/plugins/NotifyMQTT.py,sha256=cnuG4f3bYYNPhEj9qDX8SLmnxLVT9G1b8J5w6-mQGKY,19545 -apprise/plugins/NotifyMSG91.py,sha256=P7JPyT1xmucnaEeCZPf_6aJfe1gS_STYYwEM7hJ7QBw,12677 -apprise/plugins/NotifyMSTeams.py,sha256=dFH575hoLL3zRddbBKfozlYjxvPJGbj3BKvfJSIkvD0,22976 -apprise/plugins/NotifyMacOSX.py,sha256=y2fGpSZXomFiNwKbWImrXQUMVM4JR4uPCnsWpnxQrFA,8271 -apprise/plugins/NotifyMailgun.py,sha256=FNS_QLOQWMo62yVO-mMZkpiXudUtSdbHOjfSrLC4oIo,25409 -apprise/plugins/NotifyMastodon.py,sha256=2ovjQIOOITHH8lOinC8QCFCJN2QA8foIM2pjdknbblc,35277 -apprise/plugins/NotifyMatrix.py,sha256=I8kdaZUZS-drew0JExBbChQVe7Ib4EwAjQd0xE30XT0,50049 -apprise/plugins/NotifyMattermost.py,sha256=JgEc-wC-43FBMItezDJ62zv1Nc9ROFjDiwD_8bt8rgM,12722 -apprise/plugins/NotifyMessageBird.py,sha256=EUPwhs1PHiPZpluIrLiNKQMUPcdlKnx1sdnllCtN_Ns,12248 -apprise/plugins/NotifyMisskey.py,sha256=zYZkBKv0p3jJpm_HLDBugUgKeGb0qpLoPqy0ffwwxVg,9600 -apprise/plugins/NotifyNextcloud.py,sha256=M3EyvUzBMHbTKU3gxW_7fPA6vmQUF5x8GTMZQ78sWCA,12759 -apprise/plugins/NotifyNextcloudTalk.py,sha256=dLl_g7Knq5PVcadbzDuQsxbGHTZlC4r-pQC8wzYnmAo,11011 -apprise/plugins/NotifyNotica.py,sha256=yHmk8HiNFjzoI4Gewo_nBRrx9liEmhT95k1d10wqhYg,12990 -apprise/plugins/NotifyNotifiarr.py,sha256=ADwLJO9eenfLkNa09tXMGSBTM4c3zTY0SEePvyB8WYA,15857 -apprise/plugins/NotifyNotifico.py,sha256=Qe9jMN_M3GL4XlYIWkAf-w_Hf65g9Hde4bVuytGhUW4,12035 -apprise/plugins/NotifyNtfy.py,sha256=AtJt2zH35mMQTwRDxKia93NPy6-4rtixplP53zIYV2M,27979 -apprise/plugins/NotifyOffice365.py,sha256=8TxsVsdbUghmNj0kceMlmoZzTOKQTgn3priI8JuRuHE,25190 -apprise/plugins/NotifyOneSignal.py,sha256=gsw7ckW7xLiJDRUb7eJHNe_4bvdBXmt6_YsB1u_ghjw,18153 -apprise/plugins/NotifyOpsgenie.py,sha256=zJWpknjoHq35Iv9w88ucR62odaeIN3nrGFPtYnhDdjA,20515 -apprise/plugins/NotifyPagerDuty.py,sha256=lu6oNdygrs6UezYm6xgiQxQDeDz8EVUtfP-xsArRvyw,17874 -apprise/plugins/NotifyPagerTree.py,sha256=mPl6ejdelNlWUWGVs46kZT0VV4uFZoeCdcv4VJ_f_XQ,13849 -apprise/plugins/NotifyParsePlatform.py,sha256=6oFOTpu-HMhesaYXRBvu5oaESYlFrKBNYTHE-ItCBRk,10291 -apprise/plugins/NotifyPopcornNotify.py,sha256=kRstzG0tWBdxSRfn2RN2J7FhvIj2qYWlwUyLxxZCbPc,10587 -apprise/plugins/NotifyProwl.py,sha256=EGOdmiZq8CFbjxTtWWKLQEdYiSvr4czZfE_8aCMEokw,9782 -apprise/plugins/NotifyPushBullet.py,sha256=JVd2GQH-DWmPaKjuGBpsE6DXNCcZEUDH7tA5zbM1qEU,15372 -apprise/plugins/NotifyPushDeer.py,sha256=cG1UFG06PfzbmI1RxtrMqmfaHK_Ojk_W-QMEdtkEuUI,6922 -apprise/plugins/NotifyPushMe.py,sha256=ioRzeXbd2X5miTd3h3m7AwCqkIIfbXNm4PjYk0OOXZ0,7134 -apprise/plugins/NotifyPushSafer.py,sha256=hIcYHwUZapmC-VDvaO_UkDY9RSPTxHgF7m2FL-6JBZw,26756 -apprise/plugins/NotifyPushed.py,sha256=NqLMXD9gvihXLfLUtCcMfz5oUAhPM7sKXECqKgD0v-U,12270 -apprise/plugins/NotifyPushjet.py,sha256=8qWpIqM4dKWjO-BjOrRJXZYtvtJBt_mikdBWRxfibnE,8952 -apprise/plugins/NotifyPushover.py,sha256=MJDquV4zl1cNrGZOC55hLlt6lOb6625WeUcgS5ceCbk,21213 -apprise/plugins/NotifyPushy.py,sha256=mmWcnu905Fvc8ihYXvZ7lVYErGZH5Q-GbBNS20v5r48,12496 -apprise/plugins/NotifyRSyslog.py,sha256=W42LT90X65-pNoU7KdhdX1PBcmsz9RyV376CDa_H3CI,11982 -apprise/plugins/NotifyReddit.py,sha256=E78OSyDQfUalBEcg71sdMsNBOwdj7cVBnELrhrZEAXY,25785 -apprise/plugins/NotifyRevolt.py,sha256=DRA9Xylwl6leVjVFuJcP4L1cG49CIBtnQdxh4BKnAZ4,14500 -apprise/plugins/NotifyRocketChat.py,sha256=Cb_nasX0-G3FoPMYvNk55RJ-tHuXUCTLUn2wTSi4IcI,25738 -apprise/plugins/NotifyRyver.py,sha256=yhHPMLGeJtcHwBKSPPk0OBfp59DgTvXio1R59JhrJu4,11823 -apprise/plugins/NotifySES.py,sha256=wtRmpAZkS5mQma6sdiaPT6U1xcgoj77CB9mNFvSEAw8,33545 -apprise/plugins/NotifySMSEagle.py,sha256=voFNqOewD9OC1eRctD0YdUB_ZSWsb06rjUwBfCcxPYA,24161 -apprise/plugins/NotifySMSManager.py,sha256=DbVc35qLfYkNL7eq43_rPD6k-PELL9apf3S09S6qvDA,14125 -apprise/plugins/NotifySMTP2Go.py,sha256=foQ7aMMmNc5Oree8YwrxZJgMnF6yVMFAfqShm_nLbx0,19711 -apprise/plugins/NotifySNS.py,sha256=ZEBWf0ZJ9w_ftzUikKEvQWJ2fkxrUbrLhPmTRD2DvRQ,24159 -apprise/plugins/NotifySendGrid.py,sha256=IBdYmZcthkvGCz1N_Fs8vDnImtHug6LpuKv1mWT_Cdo,16213 -apprise/plugins/NotifyServerChan.py,sha256=WsrClO9f0xi-KpnLZGTUHV7PxeU3l1D875gvMaZRG_M,5779 -apprise/plugins/NotifySignalAPI.py,sha256=OwJ7qjJ-ZJyS8GS-dBWAtgizHMnGegg76GuwFobyWkw,16733 -apprise/plugins/NotifySimplePush.py,sha256=dUC6O8IGuUIAz5z6_H7A7jdv5Gj1plytNm5QyKnHAYg,10876 -apprise/plugins/NotifySinch.py,sha256=tmHLwQa9lWHEI3EcRfigl4i7JU46A6gKAi_GbY0PrX4,16813 -apprise/plugins/NotifySlack.py,sha256=3VdjruU5FPr3jT_s3axwRJKMcBYXP0lvJnyuKedIlcE,42521 -apprise/plugins/NotifySparkPost.py,sha256=6dRTwnYU50Lvmp6AlwCyePe0TMbVEXaSwNeGkg__EYo,27878 -apprise/plugins/NotifyStreamlabs.py,sha256=lx3N8T2ufUWFYIZ-kU_rOv50YyGWBqLSCKk7xim2_Io,16023 -apprise/plugins/NotifySynology.py,sha256=_jTqfgWeOuSi_I8geMOraHBVFtDkvm9mempzymrmeAo,11105 -apprise/plugins/NotifySyslog.py,sha256=J9Kain2bb-PDNiG5Ydb0q678cYjNE_NjZFqMG9oEXM0,10617 -apprise/plugins/NotifyTechulusPush.py,sha256=m43_Qj1scPcgCRX5Dr2Ul7nxMbaiVxNzm_HRuNmfgoA,7253 -apprise/plugins/NotifyTelegram.py,sha256=XE7PC9LRzcrfE2bpLKyor5lO_7B9LS4Xw1UlUmA4a2A,37187 -apprise/plugins/NotifyThreema.py,sha256=C_C3j0fJWgeF2uB7ceJFXOdC6Lt0TFBInFMs5Xlg04M,11885 -apprise/plugins/NotifyTwilio.py,sha256=WCo8eTI9OF1rtg3ueHHRDXt4Lp45eZ6h3IdTZVf5HM8,15976 -apprise/plugins/NotifyTwist.py,sha256=nZA73CYVe-p0tkVMy5q3vFRyflLM4yjUo9LECvkUwgc,28841 -apprise/plugins/NotifyTwitter.py,sha256=qML0jlBkLZMHrkKRxBpVUnBwAz8MWGYyI3cvwi-hrgM,30152 -apprise/plugins/NotifyVoipms.py,sha256=msy_D32YhP8OP4_Mj_L3OYd4iablqQETN-DvilGZeVQ,12552 -apprise/plugins/NotifyVonage.py,sha256=xmzZgobFaGA_whpQ5fDuG2poUrK9W4T77yP7dusHcSo,13431 -apprise/plugins/NotifyWeComBot.py,sha256=5lkhXDgyJ1edzknemKsO1sJVv7miR9F_7xI40Ag7ICI,8789 -apprise/plugins/NotifyWebexTeams.py,sha256=gbbRlHiPuOvUIZexE5m2QNd1dN_5_x0OdT5m6NSrcso,9164 -apprise/plugins/NotifyWhatsApp.py,sha256=PtzW0ue3d2wZ8Pva_LG29jUcpRRP03TFxO5SME_8Juo,19924 -apprise/plugins/NotifyWindows.py,sha256=QgWJfJF8AE6RWr-L81YYVZNWrnImK9Qr3B991HWanqU,8563 -apprise/plugins/NotifyXBMC.py,sha256=5hDuOTP3Kwtp4NEMaokNjWyEKEkQcN_fSx-cUPJvhaU,12096 -apprise/plugins/NotifyXML.py,sha256=WJnmdvXseuTRgioVMRqpR8a09cDfTpPTfuFlTnT_TfI,16973 -apprise/plugins/NotifyZulip.py,sha256=M8cSL7nZvtBYyTX6045g34tyn2vyybltgD1CoI4Xa7A,13968 -apprise/plugins/__init__.py,sha256=jTfLmW47kZC_Wf5eFFta2NoD2J-7_E7JaPrrVMIECkU,18725 -apprise/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 -apprise/utils.py,sha256=SjRU2tb1UsVnTCTXPUyXVz3WpRbDWwAHH-d3ll38EHY,53185 diff --git a/libs/alembic-1.13.1.dist-info/INSTALLER b/libs/apprise-1.9.1.dist-info/INSTALLER similarity index 100% rename from libs/alembic-1.13.1.dist-info/INSTALLER rename to libs/apprise-1.9.1.dist-info/INSTALLER diff --git a/libs/apprise-1.7.6.dist-info/LICENSE b/libs/apprise-1.9.1.dist-info/LICENSE similarity index 100% rename from libs/apprise-1.7.6.dist-info/LICENSE rename to libs/apprise-1.9.1.dist-info/LICENSE diff --git a/libs/apprise-1.7.6.dist-info/METADATA b/libs/apprise-1.9.1.dist-info/METADATA similarity index 80% rename from libs/apprise-1.7.6.dist-info/METADATA rename to libs/apprise-1.9.1.dist-info/METADATA index ac7cb9aac..212d8da2b 100644 --- a/libs/apprise-1.7.6.dist-info/METADATA +++ b/libs/apprise-1.9.1.dist-info/METADATA @@ -1,12 +1,12 @@ Metadata-Version: 2.1 Name: apprise -Version: 1.7.6 +Version: 1.9.1 Summary: Push Notifications that work with just about every platform! Home-page: https://github.com/caronc/apprise Author: Chris Caron Author-email: lead2gold@gmail.com License: BSD -Keywords: Alerts Apprise API Automated Packet Reporting System AWS Boxcar BulkSMS BulkVS Burst SMS Chantify Chat CLI ClickSend D7Networks Dapnet DBus DingTalk Discord Email Emby Enigma2 FCM Feishu Flock Form Free Mobile Gnome Google Chat Gotify Growl Guilded Home Assistant httpSMS IFTTT Join JSON Kavenegar KODI Kumulos LaMetric Line LunaSea MacOSX Mailgun Mastodon Matrix Mattermost MessageBird Microsoft Misskey MQTT MSG91 MSTeams Nextcloud NextcloudTalk Notica Notifiarr Notifico Ntfy Office365 OneSignal Opsgenie PagerDuty PagerTree ParsePlatform PopcornNotify Prowl PushBullet Pushed Pushjet PushMe Push Notifications Pushover PushSafer Pushy PushDeer Reddit Revolt Rocket.Chat RSyslog Ryver SendGrid ServerChan SES Signal SimplePush Sinch Slack SMSEagle SMS Manager SMTP2Go SNS SparkPost Streamlabs Stride Synology Chat Syslog Techulus Telegram Threema Gateway Twilio Twist Twitter Voipms Vonage Webex WeCom Bot WhatsApp Windows XBMC XML Zulip +Keywords: Africas Talking Alerts Apprise API Automated Packet Reporting System AWS BulkSMS BulkVS Burst SMS Chantify Chat CLI ClickSend D7Networks Dapnet DBus DingTalk Discord Email Emby Enigma2 FCM Feishu Flock Form Free Mobile Gnome Google Chat Gotify Growl Guilded Home Assistant httpSMS IFTTT Join JSON Kavenegar KODI Kumulos LaMetric Line LunaSea MacOSX Mailgun Mastodon Matrix Mattermost MessageBird Microsoft Misskey MQTT MSG91 MSTeams Nextcloud NextcloudTalk Notica Notifiarr Notifico Ntfy Office365 OneSignal Opsgenie PagerDuty PagerTree ParsePlatform Plivo PopcornNotify Power Automate Prowl PushBullet Pushed Pushjet PushMe Push Notifications Pushover PushSafer Pushy PushDeer Reddit Revolt Rocket.Chat RSyslog Ryver SendGrid ServerChan Seven SES SFR Signal SimplePush Sinch Slack SMSEagle SMS Manager SMTP2Go SNS SparkPost Splunk Streamlabs Stride Synology Chat Syslog Techulus Telegram Threema Gateway Twilio Twist Twitter VictorOps Voipms Vonage Webex WeCom Bot WhatsApp Windows Workflows WxPusher XBMC XML Zulip Classifier: Development Status :: 5 - Production/Stable Classifier: Intended Audience :: Developers Classifier: Intended Audience :: System Administrators @@ -35,7 +35,6 @@ Requires-Dist: requests-oauthlib Requires-Dist: click >=5.0 Requires-Dist: markdown Requires-Dist: PyYAML -Requires-Dist: dataclasses ; python_version < "3.7" ![Apprise Logo](https://raw.githubusercontent.com/caronc/apprise/master/apprise/assets/themes/default/apprise-logo.png) @@ -78,10 +77,12 @@ System Administrators and DevOps who wish to send a notification now no longer n * [Configuration Files](#cli-configuration-files) * [File Attachments](#cli-file-attachments) * [Loading Custom Notifications/Hooks](#cli-loading-custom-notificationshooks) + * [Environment Variables](#cli-environment-variables) * [Developer API Usage](#developer-api-usage) * [Configuration Files](#api-configuration-files) * [File Attachments](#api-file-attachments) * [Loading Custom Notifications/Hooks](#api-loading-custom-notificationshooks) +* [Persistent Storage](#persistent-storage) * [More Supported Links and Documentation](#want-to-learn-more) @@ -98,7 +99,6 @@ The table below identifies the services this tool supports and some example serv | [Apprise API](https://github.com/caronc/apprise/wiki/Notify_apprise_api) | apprise:// or apprises:// | (TCP) 80 or 443 | apprise://hostname/Token | [AWS SES](https://github.com/caronc/apprise/wiki/Notify_ses) | ses:// | (TCP) 443 | ses://user@domain/AccessKeyID/AccessSecretKey/RegionName
ses://user@domain/AccessKeyID/AccessSecretKey/RegionName/email1/email2/emailN | [Bark](https://github.com/caronc/apprise/wiki/Notify_bark) | bark:// | (TCP) 80 or 443 | bark://hostname
bark://hostname/device_key
bark://hostname/device_key1/device_key2/device_keyN
barks://hostname
barks://hostname/device_key
barks://hostname/device_key1/device_key2/device_keyN -| [Boxcar](https://github.com/caronc/apprise/wiki/Notify_boxcar) | boxcar:// | (TCP) 443 | boxcar://hostname
boxcar://hostname/@tag
boxcar://hostname/device_token
boxcar://hostname/device_token1/device_token2/device_tokenN
boxcar://hostname/@tag/@tag2/device_token | [Chantify](https://github.com/caronc/apprise/wiki/Notify_chantify) | chantify:// | (TCP) 443 | chantify://token | [Discord](https://github.com/caronc/apprise/wiki/Notify_discord) | discord:// | (TCP) 443 | discord://webhook_id/webhook_token
discord://avatar@webhook_id/webhook_token | [Emby](https://github.com/caronc/apprise/wiki/Notify_emby) | emby:// or embys:// | (TCP) 8096 | emby://user@hostname/
emby://user:password@hostname @@ -122,6 +122,7 @@ The table below identifies the services this tool supports and some example serv | [Mastodon](https://github.com/caronc/apprise/wiki/Notify_mastodon) | mastodon:// or mastodons://| (TCP) 80 or 443 | mastodon://access_key@hostname
mastodon://access_key@hostname/@user
mastodon://access_key@hostname/@user1/@user2/@userN | [Matrix](https://github.com/caronc/apprise/wiki/Notify_matrix) | matrix:// or matrixs:// | (TCP) 80 or 443 | matrix://hostname
matrix://user@hostname
matrixs://user:pass@hostname:port/#room_alias
matrixs://user:pass@hostname:port/!room_id
matrixs://user:pass@hostname:port/#room_alias/!room_id/#room2
matrixs://token@hostname:port/?webhook=matrix
matrix://user:token@hostname/?webhook=slack&format=markdown | [Mattermost](https://github.com/caronc/apprise/wiki/Notify_mattermost) | mmost:// or mmosts:// | (TCP) 8065 | mmost://hostname/authkey
mmost://hostname:80/authkey
mmost://user@hostname:80/authkey
mmost://hostname/authkey?channel=channel
mmosts://hostname/authkey
mmosts://user@hostname/authkey
+| [Microsoft Power Automate / Workflows (MSTeams)](https://github.com/caronc/apprise/wiki/Notify_workflows) | workflows:// | (TCP) 443 | workflows://WorkflowID/Signature/ | [Microsoft Teams](https://github.com/caronc/apprise/wiki/Notify_msteams) | msteams:// | (TCP) 443 | msteams://TokenA/TokenB/TokenC/ | [Misskey](https://github.com/caronc/apprise/wiki/Notify_misskey) | misskey:// or misskeys://| (TCP) 80 or 443 | misskey://access_token@hostname | [MQTT](https://github.com/caronc/apprise/wiki/Notify_mqtt) | mqtt:// or mqtts:// | (TCP) 1883 or 8883 | mqtt://hostname/topic
mqtt://user@hostname/topic
mqtts://user:pass@hostname:9883/topic @@ -159,42 +160,47 @@ The table below identifies the services this tool supports and some example serv | [SimplePush](https://github.com/caronc/apprise/wiki/Notify_simplepush) | spush:// | (TCP) 443 | spush://apikey
spush://salt:password@apikey
spush://apikey?event=Apprise | [Slack](https://github.com/caronc/apprise/wiki/Notify_slack) | slack:// | (TCP) 443 | slack://TokenA/TokenB/TokenC/
slack://TokenA/TokenB/TokenC/Channel
slack://botname@TokenA/TokenB/TokenC/Channel
slack://user@TokenA/TokenB/TokenC/Channel1/Channel2/ChannelN | [SMTP2Go](https://github.com/caronc/apprise/wiki/Notify_smtp2go) | smtp2go:// | (TCP) 443 | smtp2go://user@hostname/apikey
smtp2go://user@hostname/apikey/email
smtp2go://user@hostname/apikey/email1/email2/emailN
smtp2go://user@hostname/apikey/?name="From%20User" -| [Streamlabs](https://github.com/caronc/apprise/wiki/Notify_streamlabs) | strmlabs:// | (TCP) 443 | strmlabs://AccessToken/
strmlabs://AccessToken/?name=name&identifier=identifier&amount=0¤cy=USD | [SparkPost](https://github.com/caronc/apprise/wiki/Notify_sparkpost) | sparkpost:// | (TCP) 443 | sparkpost://user@hostname/apikey
sparkpost://user@hostname/apikey/email
sparkpost://user@hostname/apikey/email1/email2/emailN
sparkpost://user@hostname/apikey/?name="From%20User" +| [Splunk](https://github.com/caronc/apprise/wiki/Notify_splunk) | splunk:// or victorops:/ | (TCP) 443 | splunk://route_key@apikey
splunk://route_key@apikey/entity_id +| [Streamlabs](https://github.com/caronc/apprise/wiki/Notify_streamlabs) | strmlabs:// | (TCP) 443 | strmlabs://AccessToken/
strmlabs://AccessToken/?name=name&identifier=identifier&amount=0¤cy=USD | [Synology Chat](https://github.com/caronc/apprise/wiki/Notify_synology_chat) | synology:// or synologys:// | (TCP) 80 or 443 | synology://hostname/token
synology://hostname:port/token | [Syslog](https://github.com/caronc/apprise/wiki/Notify_syslog) | syslog:// | n/a | syslog://
syslog://Facility | [Telegram](https://github.com/caronc/apprise/wiki/Notify_telegram) | tgram:// | (TCP) 443 | tgram://bottoken/ChatID
tgram://bottoken/ChatID1/ChatID2/ChatIDN | [Twitter](https://github.com/caronc/apprise/wiki/Notify_twitter) | twitter:// | (TCP) 443 | twitter://CKey/CSecret/AKey/ASecret
twitter://user@CKey/CSecret/AKey/ASecret
twitter://CKey/CSecret/AKey/ASecret/User1/User2/User2
twitter://CKey/CSecret/AKey/ASecret?mode=tweet | [Twist](https://github.com/caronc/apprise/wiki/Notify_twist) | twist:// | (TCP) 443 | twist://pasword:login
twist://password:login/#channel
twist://password:login/#team:channel
twist://password:login/#team:channel1/channel2/#team3:channel -| [XBMC](https://github.com/caronc/apprise/wiki/Notify_xbmc) | xbmc:// or xbmcs:// | (TCP) 8080 or 443 | xbmc://hostname
xbmc://user@hostname
xbmc://user:password@hostname:port | [Webex Teams (Cisco)](https://github.com/caronc/apprise/wiki/Notify_wxteams) | wxteams:// | (TCP) 443 | wxteams://Token | [WeCom Bot](https://github.com/caronc/apprise/wiki/Notify_wecombot) | wecombot:// | (TCP) 443 | wecombot://BotKey -| [WhatsApp](https://github.com/caronc/apprise/wiki/Notify_whatsapp) | whatsapp:// | (TCP) 443 | whatsapp://AccessToken@FromPhoneID/ToPhoneNo
whatsapp://Template:AccessToken@FromPhoneID/ToPhoneNo +| [WhatsApp](https://github.com/caronc/apprise/wiki/Notify_whatsapp) | whatsapp:// | (TCP) 443 | whatsapp://AccessToken@FromPhoneID/ToPhoneNo
whatsapp://Template:AccessToken@FromPhoneID/ToPhoneNo +| [WxPusher](https://github.com/caronc/apprise/wiki/Notify_wxpusher) | wxpusher:// | (TCP) 443 | wxpusher://AppToken@UserID1/UserID2/UserIDN
wxpusher://AppToken@Topic1/Topic2/Topic3
wxpusher://AppToken@UserID1/Topic1/ +| [XBMC](https://github.com/caronc/apprise/wiki/Notify_xbmc) | xbmc:// or xbmcs:// | (TCP) 8080 or 443 | xbmc://hostname
xbmc://user@hostname
xbmc://user:password@hostname:port | [Zulip Chat](https://github.com/caronc/apprise/wiki/Notify_zulip) | zulip:// | (TCP) 443 | zulip://botname@Organization/Token
zulip://botname@Organization/Token/Stream
zulip://botname@Organization/Token/Email ## SMS Notifications | Notification Service | Service ID | Default Port | Example Syntax | | -------------------- | ---------- | ------------ | -------------- | +| [Africas Talking](https://github.com/caronc/apprise/wiki/Notify_africas_talking) | atalk:// | (TCP) 443 | atalk://AppUser@ApiKey/ToPhoneNo
atalk://AppUser@ApiKey/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [Automated Packet Reporting System (ARPS)](https://github.com/caronc/apprise/wiki/Notify_aprs) | aprs:// | (TCP) 10152 | aprs://user:pass@callsign
aprs://user:pass@callsign1/callsign2/callsignN | [AWS SNS](https://github.com/caronc/apprise/wiki/Notify_sns) | sns:// | (TCP) 443 | sns://AccessKeyID/AccessSecretKey/RegionName/+PhoneNo
sns://AccessKeyID/AccessSecretKey/RegionName/+PhoneNo1/+PhoneNo2/+PhoneNoN
sns://AccessKeyID/AccessSecretKey/RegionName/Topic
sns://AccessKeyID/AccessSecretKey/RegionName/Topic1/Topic2/TopicN | [BulkSMS](https://github.com/caronc/apprise/wiki/Notify_bulksms) | bulksms:// | (TCP) 443 | bulksms://user:password@ToPhoneNo
bulksms://User:Password@ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [BulkVS](https://github.com/caronc/apprise/wiki/Notify_bulkvs) | bulkvs:// | (TCP) 443 | bulkvs://user:password@FromPhoneNo
bulkvs://user:password@FromPhoneNo/ToPhoneNo
bulkvs://user:password@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [Burst SMS](https://github.com/caronc/apprise/wiki/Notify_burst_sms) | burstsms:// | (TCP) 443 | burstsms://ApiKey:ApiSecret@FromPhoneNo/ToPhoneNo
burstsms://ApiKey:ApiSecret@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ -| [Burst SMS](https://github.com/caronc/apprise/wiki/Notify_burst_sms) | burstsms:// | (TCP) 443 | burstsms://ApiKey:ApiSecret@FromPhoneNo/ToPhoneNo
burstsms://ApiKey:ApiSecret@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [ClickSend](https://github.com/caronc/apprise/wiki/Notify_clicksend) | clicksend:// | (TCP) 443 | clicksend://user:pass@PhoneNo
clicksend://user:pass@ToPhoneNo1/ToPhoneNo2/ToPhoneNoN | [DAPNET](https://github.com/caronc/apprise/wiki/Notify_dapnet) | dapnet:// | (TCP) 80 | dapnet://user:pass@callsign
dapnet://user:pass@callsign1/callsign2/callsignN | [D7 Networks](https://github.com/caronc/apprise/wiki/Notify_d7networks) | d7sms:// | (TCP) 443 | d7sms://token@PhoneNo
d7sms://token@ToPhoneNo1/ToPhoneNo2/ToPhoneNoN | [DingTalk](https://github.com/caronc/apprise/wiki/Notify_dingtalk) | dingtalk:// | (TCP) 443 | dingtalk://token/
dingtalk://token/ToPhoneNo
dingtalk://token/ToPhoneNo1/ToPhoneNo2/ToPhoneNo1/ | [Free-Mobile](https://github.com/caronc/apprise/wiki/Notify_freemobile) | freemobile:// | (TCP) 443 | freemobile://user@password/ - [httpSMS](https://github.com/caronc/apprise/wiki/Notify_httpsms) | httpsms:// | (TCP) 443 | httpsms://ApiKey@FromPhoneNo
httpsms://ApiKey@FromPhoneNo/ToPhoneNo
httpsms://ApiKey@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ +| [httpSMS](https://github.com/caronc/apprise/wiki/Notify_httpsms) | httpsms:// | (TCP) 443 | httpsms://ApiKey@FromPhoneNo
httpsms://ApiKey@FromPhoneNo/ToPhoneNo
httpsms://ApiKey@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [Kavenegar](https://github.com/caronc/apprise/wiki/Notify_kavenegar) | kavenegar:// | (TCP) 443 | kavenegar://ApiKey/ToPhoneNo
kavenegar://FromPhoneNo@ApiKey/ToPhoneNo
kavenegar://ApiKey/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN | [MessageBird](https://github.com/caronc/apprise/wiki/Notify_messagebird) | msgbird:// | (TCP) 443 | msgbird://ApiKey/FromPhoneNo
msgbird://ApiKey/FromPhoneNo/ToPhoneNo
msgbird://ApiKey/FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [MSG91](https://github.com/caronc/apprise/wiki/Notify_msg91) | msg91:// | (TCP) 443 | msg91://TemplateID@AuthKey/ToPhoneNo
msg91://TemplateID@AuthKey/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ +| [Plivo](https://github.com/caronc/apprise/wiki/Notify_plivo) | plivo:// | (TCP) 443 | plivo://AuthID@Token@FromPhoneNo
plivo://AuthID@Token/FromPhoneNo/ToPhoneNo
plivo://AuthID@Token/FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ +| [Seven](https://github.com/caronc/apprise/wiki/Notify_seven) | seven:// | (TCP) 443 | seven://ApiKey/FromPhoneNo
seven://ApiKey/FromPhoneNo/ToPhoneNo
seven://ApiKey/FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ +| [Société Française du Radiotéléphone (SFR)](https://github.com/caronc/apprise/wiki/Notify_sfr) | sfr:// | (TCP) 443 | sfr://user:password>@spaceId/ToPhoneNo
sfr://user:password>@spaceId/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [Signal API](https://github.com/caronc/apprise/wiki/Notify_signal) | signal:// or signals:// | (TCP) 80 or 443 | signal://hostname:port/FromPhoneNo
signal://hostname:port/FromPhoneNo/ToPhoneNo
signal://hostname:port/FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [Sinch](https://github.com/caronc/apprise/wiki/Notify_sinch) | sinch:// | (TCP) 443 | sinch://ServicePlanId:ApiToken@FromPhoneNo
sinch://ServicePlanId:ApiToken@FromPhoneNo/ToPhoneNo
sinch://ServicePlanId:ApiToken@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/
sinch://ServicePlanId:ApiToken@ShortCode/ToPhoneNo
sinch://ServicePlanId:ApiToken@ShortCode/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [SMSEagle](https://github.com/caronc/apprise/wiki/Notify_smseagle) | smseagle:// or smseagles:// | (TCP) 80 or 443 | smseagles://hostname:port/ToPhoneNo
smseagles://hostname:port/@ToContact
smseagles://hostname:port/#ToGroup
smseagles://hostname:port/ToPhoneNo1/#ToGroup/@ToContact/ - [SMS Manager](https://github.com/caronc/apprise/wiki/Notify_sms_manager) | smsmgr:// | (TCP) 443 | smsmgr://ApiKey@ToPhoneNo
smsmgr://ApiKey@ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ +| [SMS Manager](https://github.com/caronc/apprise/wiki/Notify_sms_manager) | smsmgr:// | (TCP) 443 | smsmgr://ApiKey@ToPhoneNo
smsmgr://ApiKey@ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [Threema Gateway](https://github.com/caronc/apprise/wiki/Notify_threema) | threema:// | (TCP) 443 | threema://GatewayID@secret/ToPhoneNo
threema://GatewayID@secret/ToEmail
threema://GatewayID@secret/ToThreemaID/
threema://GatewayID@secret/ToEmail/ToThreemaID/ToPhoneNo/... | [Twilio](https://github.com/caronc/apprise/wiki/Notify_twilio) | twilio:// | (TCP) 443 | twilio://AccountSid:AuthToken@FromPhoneNo
twilio://AccountSid:AuthToken@FromPhoneNo/ToPhoneNo
twilio://AccountSid:AuthToken@FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/
twilio://AccountSid:AuthToken@FromPhoneNo/ToPhoneNo?apikey=Key
twilio://AccountSid:AuthToken@ShortCode/ToPhoneNo
twilio://AccountSid:AuthToken@ShortCode/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ | [Voipms](https://github.com/caronc/apprise/wiki/Notify_voipms) | voipms:// | (TCP) 443 | voipms://password:email/FromPhoneNo
voipms://password:email/FromPhoneNo/ToPhoneNo
voipms://password:email/FromPhoneNo/ToPhoneNo1/ToPhoneNo2/ToPhoneNoN/ @@ -386,6 +392,17 @@ apprise -vv --title 'custom override' \ You can read more about creating your own custom notifications and/or hooks [here](https://github.com/caronc/apprise/wiki/decorator_notify). +## CLI Environment Variables + +Those using the Command Line Interface (CLI) can also leverage environment variables to pre-set the default settings: + +| Variable | Description | +|------------------------ | ----------------- | +| `APPRISE_URLS` | Specify the default URLs to notify IF none are otherwise specified on the command line explicitly. If the `--config` (`-c`) is specified, then this will over-rides any reference to this variable. Use white space and/or a comma (`,`) to delimit multiple entries. +| `APPRISE_CONFIG_PATH` | Explicitly specify the config search path to use (over-riding the default). The path(s) defined here must point to the absolute filename to open/reference. Use a semi-colon (`;`), line-feed (`\n`), and/or carriage return (`\r`) to delimit multiple entries. +| `APPRISE_PLUGIN_PATH` | Explicitly specify the custom plugin search path to use (over-riding the default). Use a semi-colon (`;`), line-feed (`\n`), and/or carriage return (`\r`) to delimit multiple entries. +| `APPRISE_STORAGE_PATH` | Explicitly specify the persistent storage path to use (over-riding the default). + # Developer API Usage To send a notification from within your python application, just do the following: @@ -572,6 +589,123 @@ aobj.notify("test") You can read more about creating your own custom notifications and/or hooks [here](https://github.com/caronc/apprise/wiki/decorator_notify). +# Persistent Storage + +Persistent storage allows Apprise to cache re-occurring actions optionaly to disk. This can greatly reduce the overhead used to send a notification. + +There are 3 Persistent Storage operational states Apprise can operate using: +1. `auto`: Flush gathered cache information to the filesystem on demand. This option is incredibly light weight. This is the default behavior for all CLI usage. + * Developers who choose to use this operational mode can also force cached information manually if they choose. + * The CLI will use this operational mode by default. +1. `flush`: Flushes any cache information to the filesystem during every transaction. +1. `memory`: Effectively disable Persistent Storage. Any caching of data required by each plugin used is done in memory. Apprise effectively operates as it always did before peristent storage was available. This setting ensures no content is every written to disk. + * By default this is the mode Apprise will operate under for those developing with it unless they configure it to otherwise operate as `auto` or `flush`. This is done through the `AppriseAsset()` object and is explained further on in this documentation. + +## CLI Persistent Storage Commands + +You can provide the keyword `storage` on your CLI call to see the persistent storage options available to you. +```bash +# List all of the occupied space used by Apprise's Persistent Storage: +apprise storage list + +# list is the default option, so the following does the same thing: +apprise storage + +# You can prune all of your storage older then 30 days +# and not accessed for this period like so: +apprise storage prune + +# You can do a hard reset (and wipe all persistent storage) with: +apprise storage clean + +``` + +You can also filter your results by adding tags and/or URL Identifiers. When you get a listing (`apprise storage list`), you may see: +``` + # example output of 'apprise storage list': + 1. f7077a65 0.00B unused + - matrixs://abcdef:****@synapse.example12.com/%23general?image=no&mode=off&version=3&msgtype... + tags: team + + 2. 0e873a46 81.10B active + - tgram://W...U//?image=False&detect=yes&silent=no&preview=no&content=before&mdv=v1&format=m... + tags: personal + + 3. abcd123 12.00B stale + +``` +The (persistent storage) cache states are: + - `unused`: This plugin has not commited anything to disk for reuse/cache purposes + - `active`: This plugin has written content to disk. Or at the very least, it has prepared a persistent storage location it can write into. + - `stale`: The system detected a location where a URL may have possibly written to in the past, but there is nothing linking to it using the URLs provided. It is likely wasting space or is no longer of any use. + +You can use this information to filter your results by specifying _URL ID_ (UID) values after your command. For example: +```bash +# The below commands continue with the example already identified above +# the following would match abcd123 (even though just ab was provided) +# The output would only list the 'stale' entry above +apprise storage list ab + +# knowing our filter is safe, we could remove it +# the below command would not obstruct our other to URLs and would only +# remove our stale one: +apprise storage clean ab + +# Entries can be filtered by tag as well: +apprise storage list --tag=team + +# You can match on multiple URL ID's as well: +# The followin would actually match the URL ID's of 1. and .2 above +apprise storage list f 0 +``` +When using the CLI, Persistent storage is set to the operational mode of `auto` by default, you can change this by providing `--storage-mode=` (`-SM`) during your calls. If you want to ensure it's always set to a value of your choice. + +For more information on persistent storage, [visit here](https://github.com/caronc/apprise/wiki/persistent_storage). + +## API Persistent Storage Commands +For developers, persistent storage is set in the operational mode of `memory` by default. + +It's at the developers discretion to enable it (by switching it to either `auto` or `flush`). Should you choose to do so: it's as easy as including the information in the `AppriseAsset()` object prior to the initialization of your `Apprise()` instance. + +For example: +```python +from apprise import Apprise +from apprise import AppriseAsset +from apprise import PersistentStoreMode + +# Prepare a location the persistent storage can write it's cached content to. +# By setting this path, this immediately assumes you wish to operate the +# persistent storage in the operational 'auto' mode +asset = AppriseAsset(storage_path="/path/to/save/data") + +# If you want to be more explicit and set more options, then you may do the +# following +asset = AppriseAsset( + # Set our storage path directory (minimum requirement to enable it) + storage_path="/path/to/save/data", + + # Set the mode... the options are: + # 1. PersistentStoreMode.MEMORY + # - disable persistent storage from writing to disk + # 2. PersistentStoreMode.AUTO + # - write to disk on demand + # 3. PersistentStoreMode.FLUSH + # - write to disk always and often + storage_mode=PersistentStoreMode.FLUSH + + # The URL IDs are by default 8 characters in length. You can increase and + # decrease it's value here. The value must be > 2. The default value is 8 + # if not otherwise specified + storage_idlen=8, +) + +# Now that we've got our asset, we just work with our Apprise object as we +# normally do +aobj = Apprise(asset=asset) +``` + +For more information on persistent storage, [visit here](https://github.com/caronc/apprise/wiki/persistent_storage). + # Want To Learn More? If you're interested in reading more about this and other methods on how to customize your own notifications, please check out the following links: @@ -580,6 +714,7 @@ If you're interested in reading more about this and other methods on how to cust * 🔧 [Troubleshooting](https://github.com/caronc/apprise/wiki/Troubleshooting) * ⚙️ [Configuration File Help](https://github.com/caronc/apprise/wiki/config) * ⚡ [Create Your Own Custom Notifications](https://github.com/caronc/apprise/wiki/decorator_notify) +* 💾 [Persistent Storage](https://github.com/caronc/apprise/wiki/persistent_storage) * 🌎 [Apprise API/Web Interface](https://github.com/caronc/apprise-api) * 🎉 [Showcase](https://github.com/caronc/apprise/wiki/showcase) diff --git a/libs/apprise-1.9.1.dist-info/RECORD b/libs/apprise-1.9.1.dist-info/RECORD new file mode 100644 index 000000000..871ad1223 --- /dev/null +++ b/libs/apprise-1.9.1.dist-info/RECORD @@ -0,0 +1,204 @@ +../../bin/apprise,sha256=ZJ-e4qqxNLtdW_DAvpuPPX5iROIiQd8I6nvg7vtAv-g,233 +apprise-1.9.1.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4 +apprise-1.9.1.dist-info/LICENSE,sha256=gt7qKBxRhVcdmXCYVtrWP6DtYjD0DzONet600dkU994,1343 +apprise-1.9.1.dist-info/METADATA,sha256=UayxDNlv8dJVozBLd5Nvj_q6vZfELt5ycUOJCoyk8bo,52950 +apprise-1.9.1.dist-info/RECORD,, +apprise-1.9.1.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +apprise-1.9.1.dist-info/WHEEL,sha256=Xo9-1PvkuimrydujYJAjF7pCkriuXBpUPEjma1nZyJ0,92 +apprise-1.9.1.dist-info/entry_points.txt,sha256=71YypBuNdjAKiaLsiMG40HEfLHxkU4Mi7o_S0s0d8wI,45 +apprise-1.9.1.dist-info/top_level.txt,sha256=JrCRn-_rXw5LMKXkIgMSE4E0t1Ks9TYrBH54Pflwjkk,8 +apprise/__init__.py,sha256=RxGvVVtaXg0sEAhIEraiFg1L61jM6k_YLG8DTa97yPo,3887 +apprise/apprise.py,sha256=kB1Oc5hHGnVx_vLX8TafMUq4bopxYnBiaxkCb8QpC7M,32838 +apprise/apprise.pyi,sha256=_4TBKvT-QVj3s6PuTh3YX-BbQMeJTdBGdVpubLMY4_k,2203 +apprise/apprise_attachment.py,sha256=Dz4ZIEueRsGVyulyI1ulugdSXIcDKCATn990UukxaNU,12363 +apprise/apprise_attachment.pyi,sha256=R9-0dVqWpeaFrVpcREwPhGy3qHWztG5jEjYIOsbE5dM,1145 +apprise/apprise_config.py,sha256=wCSvFW57LkeyvG-WGnLlfUSU9s8hl3P0L8V3Q4Q8wP8,16890 +apprise/apprise_config.pyi,sha256=_mUlCnncqAq8sL01WxQTgZjnb2ic9kZXvtqZmVl-fc8,1568 +apprise/asset.py,sha256=U5FXcQFCXP5R7MnxcxdLXzEZ7IidLHBzQjUyc5l1Y0U,15103 +apprise/asset.pyi,sha256=NYLXXYbScgRkspP27XGpRRM_uliPu1OCdWdZBPPvLng,979 +apprise/assets/NotifyXML-1.0.xsd,sha256=292qQ_IUl5EWDhPyzm9UTT0C2rVvJkyGar8jiODkJs8,986 +apprise/assets/NotifyXML-1.1.xsd,sha256=bjR3CGG4AEXoJjYkGCbDttKHSkPP1FlIWO02E7G59g4,1758 +apprise/assets/themes/default/apprise-failure-128x128.ico,sha256=Mt0ptfHJaN3Wsv5UCNDn9_3lyEDHxVDv1JdaDEI_xCA,67646 +apprise/assets/themes/default/apprise-failure-128x128.png,sha256=66ps8TDPxVH3g9PlObJqF-0x952CjnqQyN3zvpRcOT8,16135 +apprise/assets/themes/default/apprise-failure-256x256.png,sha256=bQBsKKCsKfR9EqgYOZrcVcVa5y8qG58PN2mEqO5eNRI,41931 +apprise/assets/themes/default/apprise-failure-32x32.png,sha256=vH0pZffIDCvkejpr3fJHGXW__8Yc3R_p0bacX6t6l18,2437 +apprise/assets/themes/default/apprise-failure-72x72.png,sha256=EP5A8DHRDr9srgupFSwOoyQ308bNJ8aL192J_L4K-ec,7600 +apprise/assets/themes/default/apprise-info-128x128.ico,sha256=F5_CirmXueRCRI5Z_Crf6TS6jVIXTJlRD83zw1oJ66g,67646 +apprise/assets/themes/default/apprise-info-128x128.png,sha256=bBqRZAgQey-gkmJrnFhPbzjILSrljE59mRkgj3raMQo,16671 +apprise/assets/themes/default/apprise-info-256x256.png,sha256=B5r_O4d9MHCmSWZwfbqQgZSp-ZetTdiBSwKcMTF1aFA,43331 +apprise/assets/themes/default/apprise-info-32x32.png,sha256=lt3NZ95TzkiCNVNlurrB2fE2nriMa1wftl7nrNXmb6c,2485 +apprise/assets/themes/default/apprise-info-72x72.png,sha256=kDnsZpqNUZGqs9t1ECUup7FOfXUIL-rupnQCYJp9So4,7875 +apprise/assets/themes/default/apprise-logo.png,sha256=85ttALudKkLmiqilJT7mUQLUXRFmM1AK89rnwLm313s,160907 +apprise/assets/themes/default/apprise-success-128x128.ico,sha256=uCopPwdQjxgfohKazHaDzYs9y4oiaOpL048PYC6WRlg,67646 +apprise/assets/themes/default/apprise-success-128x128.png,sha256=nvDuU_QqhGlw6cMtdj7Mv-gPgqCEx-0DaaXn1KBLVYg,17446 +apprise/assets/themes/default/apprise-success-256x256.png,sha256=vXfKuxY3n0eeXHKdb9hTxICxOEn7HjAQ4IZpX0HSLzc,48729 +apprise/assets/themes/default/apprise-success-32x32.png,sha256=Jg9pFJh3YPI-LiPBebyJ7Z4Vt7BRecaE8AsRjQVIkME,2471 +apprise/assets/themes/default/apprise-success-72x72.png,sha256=FQbgvIhqKOhEK0yvrhaSpai0R7hrkTt_-GaC2KUgCCk,7858 +apprise/assets/themes/default/apprise-warning-128x128.ico,sha256=6XaQPOx0oWK_xbhr4Yhb7qNazCWwSs9lk2SYR2MHTrQ,67646 +apprise/assets/themes/default/apprise-warning-128x128.png,sha256=pf5c4Ph7jWH7gf39dJoieSj8TzAsY3TXI-sGISGVIW4,16784 +apprise/assets/themes/default/apprise-warning-256x256.png,sha256=SY-xlaiXaj420iEYKC2_fJxU-yj2SuaQg6xfPNi83bw,43708 +apprise/assets/themes/default/apprise-warning-32x32.png,sha256=97R2ywNvcwczhBoWEIgajVtWjgT8fLs4FCCz4wu0dwc,2472 +apprise/assets/themes/default/apprise-warning-72x72.png,sha256=L8moEInkO_OLxoOcuvN7rmrGZo64iJeH20o-24MQghE,7913 +apprise/attachment/__init__.py,sha256=9jSiGEbLllS-0Vbpgxo4MCpZfIJ-saezWWgQ1PofZ9I,1678 +apprise/attachment/base.py,sha256=cSMzn0afNynXTnqTnyMgue__8ya_OPo5azMwTYOKS2k,16509 +apprise/attachment/base.pyi,sha256=w0XG_QKauiMLJ7eQ4S57IiLIURZHm_Snw7l6-ih9GP8,961 +apprise/attachment/file.py,sha256=t_osFOH0QD6KuuywZfKN61aQdAjxTFff5BgFNBzWoY0,4976 +apprise/attachment/http.py,sha256=o9341E3G8SZGQ1uDkN6Rto1WeKXHRibkraocOiUvm8Y,13758 +apprise/attachment/memory.py,sha256=TlDRGqoelOhaFkKW6bMDm8mw7gJLYMNHH4aDAL2MvHg,6999 +apprise/cli.py,sha256=upLvnKnD_Lo4EMv-S-LJQ-ZP6AoUxJSR7S6U17XEXrc,35327 +apprise/common.py,sha256=B_4Nwo8HejUDnqdeya6Vvn-y6pkFXBpeHJy9uejEujc,6524 +apprise/common.pyi,sha256=luF3QRiClDCk8Z23rI6FCGYsVmodOt_JYfYyzGogdNM,447 +apprise/config/__init__.py,sha256=oDxdoqG2NEYu_bbpLsLaM3L9WKY3gNn5gjIwb2h3LU4,1679 +apprise/config/base.py,sha256=QSGJt7-gmK7gI_chB2YukBEU5vsiaCjz7wUG50SU2a4,53129 +apprise/config/base.pyi,sha256=cngfobwH6v2vxYbQrObDi5Z-t5wcquWF-wR0kBCr3Eg,54 +apprise/config/file.py,sha256=A54MYobIVIQqbrhYmC26foMKnDN83Hz7U_go9gL802A,6245 +apprise/config/http.py,sha256=CjQtv_OQJykMxD4ssiwbFI3P8CeQiPdYV_ZIiL_XqMw,9440 +apprise/config/memory.py,sha256=8VICU-WLux8KnW6i9sf9fgmns90J-MfVYI3pvTiyTno,2816 +apprise/conversion.py,sha256=7q00QmrT0TCWu49j6TnPc0l_vfCIWCdDT3KiSq3SLJc,6350 +apprise/decorators/__init__.py,sha256=e_PDAm0kQNzwDPx-NJZLPfLMd2VAABvNZtxx_iDviRM,1487 +apprise/decorators/base.py,sha256=HgqmvjqRel421G0SW-6H0zGYAC-o4E2nj9-w_ifu-50,8084 +apprise/decorators/notify.py,sha256=FzIa7m-G5KnVVa__rjyn990zt2lqE8sdHW-XY79lbAU,5097 +apprise/emojis.py,sha256=ONF0t8dY9f2XlEkLUG79-ybKVAj2GqbPj2-Be97vAoI,87738 +apprise/exception.py,sha256=5E-jQ6qA0hQHVnE9sjVLRuj4d0QgGJQ5HdtCui215XU,2337 +apprise/i18n/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +apprise/i18n/en/LC_MESSAGES/apprise.mo,sha256=aOLjg1I4K-9KQEqyFhs7wW13jUPjxY4X7EN2XvYR8Qw,3959 +apprise/locale.py,sha256=4uSr4Nj_rz6ISMMAfRVRk58wZVLKOofJgk2x0_E8NkQ,8994 +apprise/logger.py,sha256=131hqhed8cUj9x_mfXDEvwA2YbcYDFAYiWVK1HgxRVY,6921 +apprise/manager.py,sha256=ZgpusHA3yAyUf57qxOtBDcw4IICMKBNTveCAEqSm7wk,27352 +apprise/manager_attachment.py,sha256=EkrcKtKjbxTXXyDaKyiu4XSfDu9wKFSdJD7TOTpyOUc,2200 +apprise/manager_config.py,sha256=sCIOlBTH13bQ1cuhQVpUq2vyKWBArA8YRyPnXIB1iWQ,2205 +apprise/manager_plugins.py,sha256=Xbg5-xr-06zMIDoWviz-zKcbzusHj0iBBMchKPpUOkg,2211 +apprise/persistent_store.py,sha256=Kn-iwudFQiUerlLdSquTLoGMEgFTYXcrHyK4tU4RMEE,56446 +apprise/plugins/__init__.py,sha256=CvH8porbgIJTuDqK6E3Ejy_7n_yOOsI4baRZF3TYDMU,18695 +apprise/plugins/africas_talking.py,sha256=0Mcc7FhxPTBDhWxoF5Etfzyw0Dfkn4vApEmAWw3oq4U,16028 +apprise/plugins/apprise_api.py,sha256=OGpwoNkujgg1MvlEYlz0yYiJYdvISnR5R9L_3v_I5yw,16802 +apprise/plugins/aprs.py,sha256=72sfop7ulQ8g5JIa1KfBvWpj-dSV9pv0CtT2ZQToT7A,25779 +apprise/plugins/bark.py,sha256=MaWLHKHs6dCaMjKLy9wEx62ec6n1i9pD_0u3ZaHVzWw,17079 +apprise/plugins/base.py,sha256=A9octO0MKqDH195PVPXoARqqTLozqxcvcUKGqput3o8,32784 +apprise/plugins/base.pyi,sha256=aKlZXRYUgG8lz_ZgGkYYJ_GKhuf18youTmMU-FlG7z8,21 +apprise/plugins/bulksms.py,sha256=3IrRv7N2c2jTbJ2uwKPOi0_5DF9Gu3zgWdbO8FI665g,16346 +apprise/plugins/bulkvs.py,sha256=hi620_T4rPOTqMDJdqtsQqvihL6r4I1OQD6ag1F5fOY,13549 +apprise/plugins/burstsms.py,sha256=VLHaPfJJ1lLt7uFi1oJdbE3-j6z4mNBX1ObY3ottAIM,15797 +apprise/plugins/chantify.py,sha256=9xjBwOY4syj94g25_2ndfPjHgJbcxqTjST8epqtqLmI,6947 +apprise/plugins/clicksend.py,sha256=-ifaogTKQ7L4JtOOrdxk3WS7WOm92oNNVYmx4w1v9aU,11671 +apprise/plugins/custom_form.py,sha256=BV1adXR5NMcB5JMuylUXYIiRqy2cm8P5kRoM86EGpXY,18443 +apprise/plugins/custom_json.py,sha256=9v9r8EEA6tPEt5sfLubpLNVbgFT1jkomT383monPeYA,14312 +apprise/plugins/custom_xml.py,sha256=ihl0uYOTYO9e80RashRj2lIiIYtjAb9PPywKNy4xOi8,17569 +apprise/plugins/d7networks.py,sha256=42YgB9ukIqrGQWH68vsl0Ci-2qqVO4Bop2M4Bo_ejt0,15267 +apprise/plugins/dapnet.py,sha256=xFbPpFp64zxalW0zw9MV-gTyPUGgqG1HUtogPUtuNro,13858 +apprise/plugins/dbus.py,sha256=MXPGaWI9EEpOlncO1t9KBVUn3W6OfAqJKXhmWQzRNoc,14491 +apprise/plugins/dingtalk.py,sha256=gmKuqDXt7nmLtKltY7myJguFTTmz_vnDYOkecAmoLaM,12273 +apprise/plugins/discord.py,sha256=INdWWHcY5LmcamN4bvJCGbYjJ0vX_VfIZ-SqduYoVX0,26346 +apprise/plugins/email/__init__.py,sha256=pUp4xX_1_LC6ITIkj7lCNFiFgb8UfmLxpevnW-xKcSs,2101 +apprise/plugins/email/base.py,sha256=V-Gbu3eD2efvu7poTRajsa6SPOFz6HyGaQbbz56p9XA,38373 +apprise/plugins/email/common.py,sha256=jzuFhKMeyoTCfRtbKu3zi4v-c51Ewr66fE0lfUfh2Tg,2569 +apprise/plugins/email/templates.py,sha256=js9M2lkvay_-lbxRPCVyvP7xraSe2HV082aUaXV8wpA,9376 +apprise/plugins/emby.py,sha256=HHyXbT2Ld23-tEkMjfFgHyOSToPwnU0epWVNd3AQj2Y,24427 +apprise/plugins/enigma2.py,sha256=s3cKfNUGVe7_IcRCvdPMZ4hv_A5g7dcOvW6-aKz6-dI,11931 +apprise/plugins/fcm/__init__.py,sha256=CX-aZVcFw6nUcC7sWXHbSnU9fp_nR0IxDZyOyGlq8uQ,21936 +apprise/plugins/fcm/color.py,sha256=2DfMOAXkLU07XzT8XIYKZ5yXBw77MiMXueVaDSlWWQU,4591 +apprise/plugins/fcm/common.py,sha256=978uBUoNdtopCtylipGiKQdsQ8FTONxkFBp7uJMZHc8,1718 +apprise/plugins/fcm/oauth.py,sha256=Vvbd0-rd5BPIjAneG3rILU153JIzfSZ0kaDov6hm96M,11197 +apprise/plugins/fcm/priority.py,sha256=0WuRW1y1HVnybgjlTeCZPHzt7j8SwWnC7faNcjioAOc,8163 +apprise/plugins/feishu.py,sha256=AoTytULz_b1mzEh9tE9BahAZcveUu12bSrHbXOLBfdo,7540 +apprise/plugins/flock.py,sha256=JGjLeIagtCP79DLtHb9V9ESy7AusHLbV8rBdHrri3QU,12984 +apprise/plugins/freemobile.py,sha256=Wefq8No34MKfLewanvvOlREFZhjWFTcLxJDpD2_R7Eo,6974 +apprise/plugins/gnome.py,sha256=p87RCNgjMmDN7SFOwX_xuMGha7SIz9lVQelXJ0OlvMY,9227 +apprise/plugins/google_chat.py,sha256=XJCienlBBFc63KFc8ajlIQ5VKOoJZZNB5hG2G0y3r2g,12973 +apprise/plugins/gotify.py,sha256=OZapVd5E71xRsF7Ktpe4U70uwImjocW_li92WLXxecg,11028 +apprise/plugins/growl.py,sha256=QSmVkVTHEdve8fCb8tOK9FVQmV5cy4l2rbU4Vxkl0Qo,14446 +apprise/plugins/guilded.py,sha256=lhE6gK7laQNYEubD9lBGRemFK-IHis_u2tNekbUoGgM,3707 +apprise/plugins/home_assistant.py,sha256=1eEYQNpabnMvnYbn0xcQoUjl4jLQqGNoVYtMu094JX4,11276 +apprise/plugins/httpsms.py,sha256=o53aEDp-7DWa82h8D2By55DLz_dfZOsW9_jXtcHzxkU,11386 +apprise/plugins/ifttt.py,sha256=_oBxKsAQcPrSGAJ-KaV19P8HjXABZqE3q4PI5ooTyaY,13685 +apprise/plugins/join.py,sha256=JvMIesIOWf6Tbe8penPbqccleQ1rJeUU7g0lopGJlQQ,13818 +apprise/plugins/kavenegar.py,sha256=ES69k9nNUQuMf5IqwWQ55hmkJODShYh1b_v-H8phXew,12870 +apprise/plugins/kumulos.py,sha256=r1WFh4RrYSCwa5S30x4oRyniJhf8GKa0Nn_C3uzbBcg,8505 +apprise/plugins/lametric.py,sha256=wWMwryKwewIBUsOBybFeWPYrvjDQBNc67jaU59oktQ0,38449 +apprise/plugins/line.py,sha256=Py5GU-uNj-9rzq8NcVG-_5Hpo1nKBbhD8z1D8s_Xo9I,10908 +apprise/plugins/lunasea.py,sha256=4m7f8kn1hwkQ4HaiOttC4kPtyJYrhtQ3lBrrC9Q5OrU,15089 +apprise/plugins/macosx.py,sha256=BMPB3muOKen_EkRcxKdIF4Iz_4kY7NW7e0Ruau61iiY,8439 +apprise/plugins/mailgun.py,sha256=adcMibdGEHIIW-fhIsSwJC2dWfmiXhKq9_oHfC4ZHpM,25868 +apprise/plugins/mastodon.py,sha256=2Bf6dNbXStvB8DPFp-9nxGTG78qxtm-h3o-24AhgQLg,35610 +apprise/plugins/matrix.py,sha256=vVvm-gmpAHt-2aUA2BlDOwNa-2ovGSkmVf1G5JUWAUo,63276 +apprise/plugins/mattermost.py,sha256=uLZfzDAMhkkFcdMK7qsKvTxsYt7EjcLC_8lcXRDBmZ4,14813 +apprise/plugins/messagebird.py,sha256=XRUWILix-Gjo3tc4aUlnb0pyuhi6kUXkXcHPqgbHscA,12498 +apprise/plugins/misskey.py,sha256=UMEluu2KmGb7lHcjn6vFwacjFHVOSN-9WLXmy5qZrO0,9909 +apprise/plugins/mqtt.py,sha256=XRMwYPEVYeb8beBeqNTFa732L7ES2FHYcQ1mX7-D6Sc,20630 +apprise/plugins/msg91.py,sha256=sLFJrMckUfRjySwXbQ_Pu6YDCRB6MqICBotnOY_jFFU,12937 +apprise/plugins/msteams.py,sha256=3azwj5kLkPpOiLi2R6gmIsT03wNCbfKX8cPSv2XkMUg,26653 +apprise/plugins/nextcloud.py,sha256=YLdiR9I23iVxdP7kgpLblim7DN2JGh1bfGJvY5_BNZI,13134 +apprise/plugins/nextcloudtalk.py,sha256=krD8kr9J2g0XLADHa8aPdoIyj01UBJ7MhWJ2jlb2IfQ,11386 +apprise/plugins/notica.py,sha256=yR4IMVxzgzQ0I0BveVkLPl6q0ma_0kUwWnkDAafPSpM,13400 +apprise/plugins/notifiarr.py,sha256=LZuvq7lmKXekJHcKrodXopgfJWg_yqhtk-9nt9k-X6g,15516 +apprise/plugins/notifico.py,sha256=BUkras-ZeZg1i1sg-jlwC4KHRqL43tDVhaHT6Cj55uc,12309 +apprise/plugins/ntfy.py,sha256=O0hVLLvx9GUPgtJIJxZ5TpAmHfe31iCbqJ5sDABhljk,30163 +apprise/plugins/office365.py,sha256=8gLy1ITiG92RAQSKGlWwZtudMHjFTbSGzgTXPxHr93s,36877 +apprise/plugins/one_signal.py,sha256=oqFjJG-JwYgggCk7qJll6ODGVYcOn5aFNt_JZHfDVhk,22555 +apprise/plugins/opsgenie.py,sha256=CgSC7ELgvkqchhHDhbRMcuT7mjyZMKdGhyfBgg8Lz64,28428 +apprise/plugins/pagerduty.py,sha256=8sGPW7xAaoUdUdWF4HD9X3f0g6CyXpvYV440lL3klps,18196 +apprise/plugins/pagertree.py,sha256=VaKfof2NE_zVxsQpzgkmClofhRZDFIeqXJyefxSLQGc,14110 +apprise/plugins/parseplatform.py,sha256=txtC-xATfL2VAa-7aEGCiaoT50vWisfgz35A0NzDiR0,10709 +apprise/plugins/plivo.py,sha256=wYDlwG6XstiDO82N8uRLdAUGiq_hj0_q3Vle4g1NjyY,13840 +apprise/plugins/popcorn_notify.py,sha256=dJxDapVSzQh9-ENmb6RI8_278hi6zC0ceKMKypzmKek,10793 +apprise/plugins/prowl.py,sha256=2axHoKN2HCy4T8ByG85eX70rO2-TC8s-JSevQiQOmaw,10075 +apprise/plugins/pushbullet.py,sha256=Gh3NvSk4-SEaECtrjCtSyEDIIfq1XSq1rrjSvYv1DJU,15695 +apprise/plugins/pushdeer.py,sha256=B5ghGWNQdkdR_SYwFpFe6ZIhDpck4B5e1F8Y30lx6NA,7290 +apprise/plugins/pushed.py,sha256=KkvkIIZd_mkOPjPnfUK3YeGT_AlYK8aw8Q4J0pcIdHA,12540 +apprise/plugins/pushjet.py,sha256=H0R92-tqdeYiLsPQ7sam9lDXvJr3KxChH9_txIvegsM,9344 +apprise/plugins/pushme.py,sha256=jB0G2oDU9kw7l4feF8Vr9sqUZT_of_Y8KdBxzoFVN5I,7389 +apprise/plugins/pushover.py,sha256=uSgHUsnWn8AGbLCL0ZhKUXh6kHD1HGTNUMPeAYZLfWU,21477 +apprise/plugins/pushsafer.py,sha256=1vOL0I-_MD6pDwIEcz7Em1_nF70Af94Br8TNjiFRIBo,27166 +apprise/plugins/pushy.py,sha256=BussLKjyUcVZmJkOj1seNGHfm3P_dsI5mW2uu5wUtvY,12752 +apprise/plugins/reddit.py,sha256=TBGdBXUeV5CW3LrVFtb5OhNS9k5HWc40Umf_TpcjLlc,26102 +apprise/plugins/revolt.py,sha256=Ks8OEmsEUNNYcJuSKqc45yDe7phobkBlRg4MbBezH8Y,14759 +apprise/plugins/rocketchat.py,sha256=32yTz_bq7MniGYpbQAyhvp7nrKiwB3_UdAeOqdUo6CY,26293 +apprise/plugins/rsyslog.py,sha256=TQG0JhcYxDzLNaP-3OxnnaYuxMinoNyx0c3QAKsDVqM,12364 +apprise/plugins/ryver.py,sha256=dxV8518h45BDDpn4rpe4D3xwMhiuKPvreRrOfJhpAOQ,12097 +apprise/plugins/sendgrid.py,sha256=IMlQftYmA9xtUUcmd4p7wY1KAdg3c1_jlWnOWWbSCjw,18087 +apprise/plugins/serverchan.py,sha256=39IWtP_1qBlTvrRiGtGaJ87Z8cikYFdErKPz4_8AMhg,6054 +apprise/plugins/ses.py,sha256=qlKtvbhnAYGgT0N9b5_s0Ppys5LnfwnNjrSKpmHU8HM,34032 +apprise/plugins/seven.py,sha256=yw7yNHCAfSqrnDYoiuCIKRUSEZbMau1b7EAWBBftht8,10422 +apprise/plugins/sfr.py,sha256=wEV4CPLUPhlCSRTDBLywdTgEI5FL-_7QxnLciixuc_Y,15192 +apprise/plugins/signal_api.py,sha256=6N7P1cWxnjfba1wObwnAp968GTm1zx-r-BMevFWJi64,17061 +apprise/plugins/simplepush.py,sha256=9Wl-67UeNUR8-Wg53F82jbtvwApIEieHZOLNN2_d_L8,12049 +apprise/plugins/sinch.py,sha256=PwJ5ntzPFu4RRtP8aJC76OrQx8p0y7ccZXmLYhdDxvU,17153 +apprise/plugins/slack.py,sha256=-rkv6AemHeZW9cluaw-oouG09AK39SVxorhkxcdAJQg,44304 +apprise/plugins/smseagle.py,sha256=Jt9MBVQcbEyzyA59GYeUepAIPpnjpK4pE3NOUXwU3mE,24483 +apprise/plugins/smsmanager.py,sha256=aRlYTKpktu8VH6ZxOF9FNzGTqEe1MGNEyazi3vTLVQ8,14353 +apprise/plugins/smtp2go.py,sha256=fiHS0o2VxU6cQpxfEiZoqOf7EQPKrw9wL2LL0SyG1vw,19984 +apprise/plugins/sns.py,sha256=g1MU8t04pW9LxKFKgjEovzHMGKshst-uhr2_YrPmkC0,24488 +apprise/plugins/sparkpost.py,sha256=_mTyvFOfQx12QWOXumaABTaQ09DGSGx6HN-dUgcBc9w,28113 +apprise/plugins/splunk.py,sha256=AgZSR8IX_TZywEbLoH_L7cCFtcU764cqoxzy0ktv47Y,16607 +apprise/plugins/streamlabs.py,sha256=DwuaI0I3lV4UrMrscJBESVg4WoXk7FRDFV2JDWKSXh4,16306 +apprise/plugins/synology.py,sha256=VLoZU-UZnO2wxIbsc9UhN34zJ7H7ZRmjrcm_xTZsx2Q,11525 +apprise/plugins/syslog.py,sha256=czReqzBuJjjsBs103DMJ_niDmLC-Y8gwmmHfmENHmDQ,10785 +apprise/plugins/techuluspush.py,sha256=atMvgOdOUEdKhETWK1NSOjO7Fy6LwDtgAZmP_Ul7DV0,7528 +apprise/plugins/telegram.py,sha256=b8VoM5i56DrFNCoSltar5NX1TJivxtRhyNyQhdW2UXQ,38487 +apprise/plugins/threema.py,sha256=UH_WXZg4ftDN1JoWRMotsbD4CU3LtailDbILgnK01OY,12110 +apprise/plugins/twilio.py,sha256=iPmHSaI2FDVJxlbrMJOYFlmoO5yRSk4nk9LJdJ5AcnA,18468 +apprise/plugins/twist.py,sha256=ZxojFBgq4swlrF2UbZPpT5Zb1GrOBASa5hb71YwPIv0,29197 +apprise/plugins/twitter.py,sha256=bh5s7jOw3u-DA01ovsb95a_rWgmpmvCkX1T1Jw5DiGw,30612 +apprise/plugins/voipms.py,sha256=FGO3AO8Bidg-AjZqobOapWtU98ZTQTLJz4W4g8QWqoQ,13114 +apprise/plugins/vonage.py,sha256=kNjkCp7gt46BiS59K2TH0pPsiRpNt_PGb39Axe0PJJY,13680 +apprise/plugins/webexteams.py,sha256=V3SlKZVfL8zNvIha_BUKWEJEC3qrgoVazHP4MKJtits,9441 +apprise/plugins/wecombot.py,sha256=-ygeUlSF_uTZOfJH-3Zr9r2hM0eQ_DG4ISsPxHL_YjY,9061 +apprise/plugins/whatsapp.py,sha256=J-OlOhGhGljpfBBAfcY0L5aW6ykaKOoyrxIjZAHiaeg,20180 +apprise/plugins/windows.py,sha256=50gdOp36pmFI6wPkPfGFeow8BqtIZlWkuJRK3p6ogcc,8731 +apprise/plugins/workflows.py,sha256=uTSZwgUEUrM22JUcivy32c8mqIMMBLxK5j6UP8396Po,19850 +apprise/plugins/wxpusher.py,sha256=ZR3Yxe5zVDtMkjUOzRRbr3KeOyVL8KkPEgR00bwaNvs,12688 +apprise/plugins/xbmc.py,sha256=7ByWHUSjeuhfSeRZ6oCNMmDobqzKU1_o7ROwf4offR0,12742 +apprise/plugins/zulip.py,sha256=P62A7-cNqAlDryIlpJs0O72FW2baUYHeucjRxVnghx8,14360 +apprise/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0 +apprise/url.py,sha256=otU2AADtGUpEXw00dFNCMxTMgXPjY4ygOm1AuXyXuv8,35026 +apprise/url.pyi,sha256=WLaRREH7FzZ5x3-qkDkupojWGFC4uFwJ1EDt02lVs8c,520 +apprise/utils/__init__.py,sha256=8TgDzOFje6b9ybinURyyIOk_fqF6C2AtLbd_kh_Lubs,1430 +apprise/utils/base64.py,sha256=LehdJII_pMfv1f0l7wUWaBW2K3KU9oSSDgrKnMkMcMU,3167 +apprise/utils/cwe312.py,sha256=9YRbjAaBuKIb-Rp0fHXcCnnritoIKZxfb5sjTeYZshY,7375 +apprise/utils/disk.py,sha256=43-QOYDq_m48EbBSg646jjv9DcBh-aadQF7VUGXkUHw,5801 +apprise/utils/logic.py,sha256=YyUlpqcpFESK6cqmxG-aAs5m5Bjnkj884dxDaxN6Fjg,4573 +apprise/utils/module.py,sha256=G3l6DYi5uTXAtf3Tg9urhsb5zlI3vGm9uUafflNHxSU,2117 +apprise/utils/parse.py,sha256=k5MOZWR3gqn5MF2oATlwRUQSkEiHLmrcAIQ7sbC-H98,39386 +apprise/utils/pgp.py,sha256=cKYCJww5EQNP7E78BhwpbgBeCOjYmaU74-4dYNf_Vkk,11489 +apprise/utils/singleton.py,sha256=6OEIIkpRQCjYHlSWt_WWIwLOrlBCMz7tYhbUXx-lHSQ,1844 +apprise/utils/templates.py,sha256=5fifEhuLJwCFyQ5F7Z9BuqXG5UhOiqA6pxDd2GvI7Tw,3252 diff --git a/libs/alembic-1.13.1.dist-info/REQUESTED b/libs/apprise-1.9.1.dist-info/REQUESTED similarity index 100% rename from libs/alembic-1.13.1.dist-info/REQUESTED rename to libs/apprise-1.9.1.dist-info/REQUESTED diff --git a/libs/Jinja2-3.1.3.dist-info/WHEEL b/libs/apprise-1.9.1.dist-info/WHEEL similarity index 100% rename from libs/Jinja2-3.1.3.dist-info/WHEEL rename to libs/apprise-1.9.1.dist-info/WHEEL diff --git a/libs/apprise-1.7.6.dist-info/entry_points.txt b/libs/apprise-1.9.1.dist-info/entry_points.txt similarity index 100% rename from libs/apprise-1.7.6.dist-info/entry_points.txt rename to libs/apprise-1.9.1.dist-info/entry_points.txt diff --git a/libs/apprise-1.7.6.dist-info/top_level.txt b/libs/apprise-1.9.1.dist-info/top_level.txt similarity index 100% rename from libs/apprise-1.7.6.dist-info/top_level.txt rename to libs/apprise-1.9.1.dist-info/top_level.txt diff --git a/libs/apprise/__init__.py b/libs/apprise/__init__.py index 81373c75b..c231d5e3d 100644 --- a/libs/apprise/__init__.py +++ b/libs/apprise/__init__.py @@ -27,7 +27,7 @@ # POSSIBILITY OF SUCH DAMAGE. __title__ = 'Apprise' -__version__ = '1.7.6' +__version__ = '1.9.1' __author__ = 'Chris Caron' __license__ = 'BSD' __copywrite__ = 'Copyright (C) 2024 Chris Caron ' @@ -48,18 +48,25 @@ from .common import CONTENT_INCLUDE_MODES from .common import ContentLocation from .common import CONTENT_LOCATIONS +from .common import PersistentStoreMode +from .common import PERSISTENT_STORE_MODES -from .URLBase import URLBase -from .URLBase import PrivacyMode -from .plugins.NotifyBase import NotifyBase -from .config.ConfigBase import ConfigBase -from .attachment.AttachBase import AttachBase - -from .Apprise import Apprise -from .AppriseAsset import AppriseAsset -from .AppriseConfig import AppriseConfig -from .AppriseAttachment import AppriseAttachment +from .url import URLBase +from .url import PrivacyMode +from .plugins.base import NotifyBase +from .config.base import ConfigBase +from .attachment.base import AttachBase +from . import exception +from .apprise import Apprise +from .locale import AppriseLocale +from .asset import AppriseAsset +from .persistent_store import PersistentStore +from .apprise_config import AppriseConfig +from .apprise_attachment import AppriseAttachment +from .manager_attachment import AttachmentManager +from .manager_config import ConfigurationManager +from .manager_plugins import NotificationManager from . import decorators # Inherit our logging with our additional entries added to it @@ -73,7 +80,11 @@ __all__ = [ # Core 'Apprise', 'AppriseAsset', 'AppriseConfig', 'AppriseAttachment', 'URLBase', - 'NotifyBase', 'ConfigBase', 'AttachBase', + 'NotifyBase', 'ConfigBase', 'AttachBase', 'AppriseLocale', + 'PersistentStore', + + # Exceptions + 'exception', # Reference 'NotifyType', 'NotifyImageSize', 'NotifyFormat', 'OverflowMode', @@ -81,8 +92,12 @@ 'ConfigFormat', 'CONFIG_FORMATS', 'ContentIncludeMode', 'CONTENT_INCLUDE_MODES', 'ContentLocation', 'CONTENT_LOCATIONS', + 'PersistentStoreMode', 'PERSISTENT_STORE_MODES', 'PrivacyMode', + # Managers + 'NotificationManager', 'ConfigurationManager', 'AttachmentManager', + # Decorator 'decorators', diff --git a/libs/apprise/Apprise.py b/libs/apprise/apprise.py similarity index 98% rename from libs/apprise/Apprise.py rename to libs/apprise/apprise.py index 9a3e8dfc7..16125009a 100644 --- a/libs/apprise/Apprise.py +++ b/libs/apprise/apprise.py @@ -32,19 +32,18 @@ from itertools import chain from . import common from .conversion import convert_between -from .utils import is_exclusive_match -from .NotificationManager import NotificationManager -from .utils import parse_list -from .utils import parse_urls -from .utils import cwe312_url +from .utils.logic import is_exclusive_match +from .utils.parse import parse_list, parse_urls +from .utils.cwe312 import cwe312_url +from .manager_plugins import NotificationManager from .emojis import apply_emojis from .logger import logger -from .AppriseAsset import AppriseAsset -from .AppriseConfig import AppriseConfig -from .AppriseAttachment import AppriseAttachment -from .AppriseLocale import AppriseLocale -from .config.ConfigBase import ConfigBase -from .plugins.NotifyBase import NotifyBase +from .asset import AppriseAsset +from .apprise_config import AppriseConfig +from .apprise_attachment import AppriseAttachment +from .locale import AppriseLocale +from .config.base import ConfigBase +from .plugins.base import NotifyBase from . import plugins from . import __version__ diff --git a/libs/apprise/Apprise.pyi b/libs/apprise/apprise.pyi similarity index 100% rename from libs/apprise/Apprise.pyi rename to libs/apprise/apprise.pyi diff --git a/libs/apprise/AppriseAttachment.py b/libs/apprise/apprise_attachment.py similarity index 96% rename from libs/apprise/AppriseAttachment.py rename to libs/apprise/apprise_attachment.py index fcfed3af6..7b491eef1 100644 --- a/libs/apprise/AppriseAttachment.py +++ b/libs/apprise/apprise_attachment.py @@ -27,13 +27,13 @@ # POSSIBILITY OF SUCH DAMAGE. from . import URLBase -from .attachment.AttachBase import AttachBase -from .AppriseAsset import AppriseAsset -from .AttachmentManager import AttachmentManager +from .attachment.base import AttachBase +from .asset import AppriseAsset +from .manager_attachment import AttachmentManager from .logger import logger from .common import ContentLocation from .common import CONTENT_LOCATIONS -from .utils import GET_SCHEMA_RE +from .utils.parse import GET_SCHEMA_RE # Grant access to our Notification Manager Singleton A_MGR = AttachmentManager() @@ -142,13 +142,8 @@ def add(self, attachments, asset=None, cache=None): # prepare default asset asset = self.asset - if isinstance(attachments, AttachBase): - # Go ahead and just add our attachments into our list - self.attachments.append(attachments) - return True - - elif isinstance(attachments, str): - # Save our path + if isinstance(attachments, (AttachBase, str)): + # store our instance attachments = (attachments, ) elif not isinstance(attachments, (tuple, set, list)): diff --git a/libs/apprise/AppriseAttachment.pyi b/libs/apprise/apprise_attachment.pyi similarity index 100% rename from libs/apprise/AppriseAttachment.pyi rename to libs/apprise/apprise_attachment.pyi diff --git a/libs/apprise/AppriseConfig.py b/libs/apprise/apprise_config.py similarity index 98% rename from libs/apprise/AppriseConfig.py rename to libs/apprise/apprise_config.py index 7e5a9126f..c96b69f24 100644 --- a/libs/apprise/AppriseConfig.py +++ b/libs/apprise/apprise_config.py @@ -28,13 +28,12 @@ from . import ConfigBase from . import CONFIG_FORMATS -from .ConfigurationManager import ConfigurationManager +from .manager_config import ConfigurationManager from . import URLBase -from .AppriseAsset import AppriseAsset +from .asset import AppriseAsset from . import common -from .utils import GET_SCHEMA_RE -from .utils import parse_list -from .utils import is_exclusive_match +from .utils.parse import GET_SCHEMA_RE, parse_list +from .utils.logic import is_exclusive_match from .logger import logger # Grant access to our Configuration Manager Singleton diff --git a/libs/apprise/AppriseConfig.pyi b/libs/apprise/apprise_config.pyi similarity index 100% rename from libs/apprise/AppriseConfig.pyi rename to libs/apprise/apprise_config.pyi diff --git a/libs/apprise/AppriseAsset.py b/libs/apprise/asset.py similarity index 76% rename from libs/apprise/AppriseAsset.py rename to libs/apprise/asset.py index 97a7bccfb..d952e9415 100644 --- a/libs/apprise/AppriseAsset.py +++ b/libs/apprise/asset.py @@ -33,7 +33,8 @@ from os.path import isfile from os.path import abspath from .common import NotifyType -from .NotificationManager import NotificationManager +from .common import PersistentStoreMode +from .manager_plugins import NotificationManager # Grant access to our Notification Manager Singleton @@ -70,6 +71,9 @@ class AppriseAsset: NotifyType.WARNING: '#CACF29', } + # The default color to return if a mapping isn't found in our table above + default_html_color = '#888888' + # Ascii Notification ascii_notify_map = { NotifyType.INFO: '[i]', @@ -78,8 +82,8 @@ class AppriseAsset: NotifyType.WARNING: '[~]', } - # The default color to return if a mapping isn't found in our table above - default_html_color = '#888888' + # The default ascii to return if a mapping isn't found in our table above + default_ascii_chars = '[?]' # The default image extension to use default_extension = '.png' @@ -139,6 +143,12 @@ class AppriseAsset: # Defines the encoding of the content passed into Apprise encoding = 'utf-8' + # Automatically generate our Pretty Good Privacy (PGP) keys if one isn't + # present and our environment configuration allows for it. + # For example, a case where the environment wouldn't allow for it would be + # if Persistent Storage was set to `memory` + pgp_autogen = True + # For more detail see CWE-312 @ # https://cwe.mitre.org/data/definitions/312.html # @@ -154,6 +164,22 @@ class AppriseAsset: # By default, no paths are scanned. __plugin_paths = [] + # Optionally set the location of the persistent storage + # By default there is no path and thus persistent storage is not used + __storage_path = None + + # Optionally define the default salt to apply to all persistent storage + # namespace generation (unless over-ridden) + __storage_salt = b'' + + # Optionally define the namespace length of the directories created by + # the storage. If this is set to zero, then the length is pre-determined + # by the generator (sha1, md5, sha256, etc) + __storage_idlen = 8 + + # Set storage to auto + __storage_mode = PersistentStoreMode.AUTO + # All internal/system flags are prefixed with an underscore (_) # These can only be initialized using Python libraries and are not picked # up from (yaml) configuration files (if set) @@ -168,7 +194,9 @@ class AppriseAsset: # A unique identifer we can use to associate our calling source _uid = str(uuid4()) - def __init__(self, plugin_paths=None, **kwargs): + def __init__(self, plugin_paths=None, storage_path=None, + storage_mode=None, storage_salt=None, + storage_idlen=None, **kwargs): """ Asset Initialization @@ -184,8 +212,49 @@ def __init__(self, plugin_paths=None, **kwargs): if plugin_paths: # Load any decorated modules if defined + self.__plugin_paths = plugin_paths N_MGR.module_detection(plugin_paths) + if storage_path: + # Define our persistent storage path + self.__storage_path = storage_path + + if storage_mode: + # Define how our persistent storage behaves + self.__storage_mode = storage_mode + + if isinstance(storage_idlen, int): + # Define the number of characters utilized from our namespace lengh + if storage_idlen < 0: + # Unsupported type + raise ValueError( + 'AppriseAsset storage_idlen(): Value must ' + 'be an integer and > 0') + + # Store value + self.__storage_idlen = storage_idlen + + if storage_salt is not None: + # Define the number of characters utilized from our namespace lengh + + if isinstance(storage_salt, bytes): + self.__storage_salt = storage_salt + + elif isinstance(storage_salt, str): + try: + self.__storage_salt = storage_salt.encode(self.encoding) + + except UnicodeEncodeError: + # Bad data; don't pass it along + raise ValueError( + 'AppriseAsset namespace_salt(): ' + 'Value provided could not be encoded') + + else: # Unsupported + raise ValueError( + 'AppriseAsset namespace_salt(): Value provided must be ' + 'string or bytes object') + def color(self, notify_type, color_type=None): """ Returns an HTML mapped color based on passed in notify type @@ -223,9 +292,8 @@ def ascii(self, notify_type): Returns an ascii representation based on passed in notify type """ - # look our response up - return self.ascii_notify_map.get(notify_type, self.default_html_color) + return self.ascii_notify_map.get(notify_type, self.default_ascii_chars) def image_url(self, notify_type, image_size, logo=False, extension=None): """ @@ -354,3 +422,40 @@ def hex_to_int(value): """ return int(value.lstrip('#'), 16) + + @property + def plugin_paths(self): + """ + Return the plugin paths defined + """ + return self.__plugin_paths + + @property + def storage_path(self): + """ + Return the persistent storage path defined + """ + return self.__storage_path + + @property + def storage_mode(self): + """ + Return the persistent storage mode defined + """ + + return self.__storage_mode + + @property + def storage_salt(self): + """ + Return the provided namespace salt; this is always of type bytes + """ + return self.__storage_salt + + @property + def storage_idlen(self): + """ + Return the persistent storage id length + """ + + return self.__storage_idlen diff --git a/libs/apprise/AppriseAsset.pyi b/libs/apprise/asset.pyi similarity index 100% rename from libs/apprise/AppriseAsset.pyi rename to libs/apprise/asset.pyi diff --git a/libs/apprise/attachment/__init__.py b/libs/apprise/attachment/__init__.py index 0a88313d6..c2aef1eec 100644 --- a/libs/apprise/attachment/__init__.py +++ b/libs/apprise/attachment/__init__.py @@ -27,8 +27,8 @@ # POSSIBILITY OF SUCH DAMAGE. # Used for testing -from .AttachBase import AttachBase -from ..AttachmentManager import AttachmentManager +from .base import AttachBase +from ..manager_attachment import AttachmentManager # Initalize our Attachment Manager Singleton A_MGR = AttachmentManager() @@ -36,4 +36,5 @@ __all__ = [ # Reference 'AttachBase', + 'AttachmentManager', ] diff --git a/libs/apprise/attachment/AttachBase.py b/libs/apprise/attachment/base.py similarity index 76% rename from libs/apprise/attachment/AttachBase.py rename to libs/apprise/attachment/base.py index 8cb6bd5cb..b3743e036 100644 --- a/libs/apprise/attachment/AttachBase.py +++ b/libs/apprise/attachment/base.py @@ -29,10 +29,12 @@ import os import time import mimetypes -from ..URLBase import URLBase -from ..utils import parse_bool +import base64 +from .. import exception +from ..url import URLBase +from ..utils.parse import parse_bool from ..common import ContentLocation -from ..AppriseLocale import gettext_lazy as _ +from ..locale import gettext_lazy as _ class AttachBase(URLBase): @@ -148,6 +150,9 @@ def __init__(self, name=None, mimetype=None, cache=None, **kwargs): # Absolute path to attachment self.download_path = None + # Track open file pointers + self.__pointers = set() + # Set our cache flag; it can be True, False, None, or a (positive) # integer... nothing else if cache is not None: @@ -226,15 +231,14 @@ def mimetype(self): Content is cached once determied to prevent overhead of future calls. """ + if not self.exists(): + # we could not obtain our attachment + return None if self._mimetype: # return our pre-calculated cached content return self._mimetype - if not self.exists(): - # we could not obtain our attachment - return None - if not self.detected_mimetype: # guess_type() returns: (type, encoding) and sets type to None # if it can't otherwise determine it. @@ -258,32 +262,67 @@ def exists(self, retrieve_if_missing=True): Simply returns true if the object has downloaded and stored the attachment AND the attachment has not expired. """ + if self.location == ContentLocation.INACCESSIBLE: + # our content is inaccessible + return False cache = self.template_args['cache']['default'] \ if self.cache is None else self.cache - if self.download_path and os.path.isfile(self.download_path) \ - and cache: + try: + if self.download_path and os.path.isfile(self.download_path) \ + and cache: - # We have enough reason to look further into our cached content - # and verify it has not expired. - if cache is True: - # return our fixed content as is; we will always cache it - return True + # We have enough reason to look further into our cached content + # and verify it has not expired. + if cache is True: + # return our fixed content as is; we will always cache it + return True - # Verify our cache time to determine whether we will get our - # content again. - try: - age_in_sec = time.time() - os.stat(self.download_path).st_mtime + # Verify our cache time to determine whether we will get our + # content again. + age_in_sec = \ + time.time() - os.stat(self.download_path).st_mtime if age_in_sec <= cache: return True - except (OSError, IOError): - # The file is not present - pass + except (OSError, IOError): + # The file is not present + pass return False if not retrieve_if_missing else self.download() + def base64(self, encoding='ascii'): + """ + Returns the attachment object as a base64 string otherwise + None is returned if an error occurs. + + If encoding is set to None, then it is not encoded when returned + """ + if not self: + # We could not access the attachment + self.logger.error( + 'Could not access attachment {}.'.format( + self.url(privacy=True))) + raise exception.AppriseFileNotFound("Attachment Missing") + + try: + with self.open() as f: + # Prepare our Attachment in Base64 + return base64.b64encode(f.read()).decode(encoding) \ + if encoding else base64.b64encode(f.read()) + + except (TypeError, FileNotFoundError): + # We no longer have a path to open + raise exception.AppriseFileNotFound("Attachment Missing") + + except (TypeError, OSError, IOError) as e: + self.logger.warning( + 'An I/O error occurred while reading {}.'.format( + self.name if self else 'attachment')) + self.logger.debug('I/O Exception: %s' % str(e)) + raise exception.AppriseDiskIOError("Attachment Access Error") + def invalidate(self): """ Release any temporary data that may be open by child classes. @@ -295,6 +334,11 @@ def invalidate(self): - download_path: Must contain a absolute path to content - detected_mimetype: Should identify mimetype of content """ + + # Remove all open pointers + while self.__pointers: + self.__pointers.pop().close() + self.detected_name = None self.download_path = None self.detected_mimetype = None @@ -314,8 +358,43 @@ def download(self): raise NotImplementedError( "download() is implimented by the child class.") + def open(self, mode='rb'): + """ + return our file pointer and track it (we'll auto close later) + """ + pointer = open(self.path, mode=mode) + self.__pointers.add(pointer) + return pointer + + def chunk(self, size=5242880): + """ + A Generator that yield chunks of a file with the specified size. + + By default the chunk size is set to 5MB (5242880 bytes) + """ + + with self.open() as file: + while True: + chunk = file.read(size) + if not chunk: + break + + yield chunk + + def __enter__(self): + """ + support with keyword + """ + return self.open() + + def __exit__(self, value_type, value, traceback): + """ + stub to do nothing; but support exit of with statement gracefully + """ + return + @staticmethod - def parse_url(url, verify_host=True, mimetype_db=None): + def parse_url(url, verify_host=True, mimetype_db=None, sanitize=True): """Parses the URL and returns it broken apart into a dictionary. This is very specific and customized for Apprise. @@ -333,7 +412,8 @@ def parse_url(url, verify_host=True, mimetype_db=None): successful, otherwise None is returned. """ - results = URLBase.parse_url(url, verify_host=verify_host) + results = URLBase.parse_url( + url, verify_host=verify_host, sanitize=sanitize) if not results: # We're done; we failed to parse our url @@ -367,7 +447,15 @@ def __len__(self): Returns the filesize of the attachment. """ - return os.path.getsize(self.path) if self.path else 0 + if not self: + return 0 + + try: + return os.path.getsize(self.path) if self.path else 0 + + except OSError: + # OSError can occur if the file is inaccessible + return 0 def __bool__(self): """ @@ -375,3 +463,9 @@ def __bool__(self): True is returned if our content was downloaded correctly. """ return True if self.path else False + + def __del__(self): + """ + Perform any house cleaning + """ + self.invalidate() diff --git a/libs/apprise/attachment/AttachBase.pyi b/libs/apprise/attachment/base.pyi similarity index 100% rename from libs/apprise/attachment/AttachBase.pyi rename to libs/apprise/attachment/base.pyi diff --git a/libs/apprise/attachment/AttachFile.py b/libs/apprise/attachment/file.py similarity index 89% rename from libs/apprise/attachment/AttachFile.py rename to libs/apprise/attachment/file.py index 4c9c8f136..f0aeb72b6 100644 --- a/libs/apprise/attachment/AttachFile.py +++ b/libs/apprise/attachment/file.py @@ -28,9 +28,10 @@ import re import os -from .AttachBase import AttachBase +from .base import AttachBase +from ..utils.disk import path_decode from ..common import ContentLocation -from ..AppriseLocale import gettext_lazy as _ +from ..locale import gettext_lazy as _ class AttachFile(AttachBase): @@ -57,7 +58,10 @@ def __init__(self, path, **kwargs): # Store path but mark it dirty since we have not performed any # verification at this point. - self.dirty_path = os.path.expanduser(path) + self.dirty_path = path_decode(path) + + # Track our file as it was saved + self.__original_path = os.path.normpath(path) return def url(self, privacy=False, *args, **kwargs): @@ -77,8 +81,9 @@ def url(self, privacy=False, *args, **kwargs): params['name'] = self._name return 'file://{path}{params}'.format( - path=self.quote(self.dirty_path), - params='?{}'.format(self.urlencode(params)) if params else '', + path=self.quote(self.__original_path), + params='?{}'.format(self.urlencode(params, safe='/')) + if params else '', ) def download(self, **kwargs): @@ -96,7 +101,11 @@ def download(self, **kwargs): # Ensure any existing content set has been invalidated self.invalidate() - if not os.path.isfile(self.dirty_path): + try: + if not os.path.isfile(self.dirty_path): + return False + + except OSError: return False if self.max_file_size > 0 and \ diff --git a/libs/apprise/attachment/AttachHTTP.py b/libs/apprise/attachment/http.py similarity index 98% rename from libs/apprise/attachment/AttachHTTP.py rename to libs/apprise/attachment/http.py index 5a3af9467..870f7cc2b 100644 --- a/libs/apprise/attachment/AttachHTTP.py +++ b/libs/apprise/attachment/http.py @@ -31,10 +31,10 @@ import requests import threading from tempfile import NamedTemporaryFile -from .AttachBase import AttachBase +from .base import AttachBase from ..common import ContentLocation -from ..URLBase import PrivacyMode -from ..AppriseLocale import gettext_lazy as _ +from ..url import PrivacyMode +from ..locale import gettext_lazy as _ class AttachHTTP(AttachBase): @@ -296,8 +296,7 @@ def __del__(self): """ Tidy memory if open """ - with self._lock: - self.invalidate() + self.invalidate() def url(self, privacy=False, *args, **kwargs): """ @@ -353,7 +352,7 @@ def url(self, privacy=False, *args, **kwargs): port='' if self.port is None or self.port == default_port else ':{}'.format(self.port), fullpath=self.quote(self.fullpath, safe='/'), - params=self.urlencode(params), + params=self.urlencode(params, safe='/'), ) @staticmethod @@ -363,8 +362,7 @@ def parse_url(url): us to re-instantiate this object. """ - results = AttachBase.parse_url(url) - + results = AttachBase.parse_url(url, sanitize=False) if not results: # We're done early as we couldn't load the results return results diff --git a/libs/apprise/attachment/memory.py b/libs/apprise/attachment/memory.py new file mode 100644 index 000000000..c7d5dca24 --- /dev/null +++ b/libs/apprise/attachment/memory.py @@ -0,0 +1,231 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2024, Chris Caron +# +# 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. + +import re +import os +import io +import base64 +from .base import AttachBase +from .. import exception +from ..common import ContentLocation +from ..locale import gettext_lazy as _ +import uuid + + +class AttachMemory(AttachBase): + """ + A wrapper for Memory based attachment sources + """ + + # The default descriptive name associated with the service + service_name = _('Memory') + + # The default protocol + protocol = 'memory' + + # Content is local to the same location as the apprise instance + # being called (server-side) + location = ContentLocation.LOCAL + + def __init__(self, content=None, name=None, mimetype=None, + encoding='utf-8', **kwargs): + """ + Initialize Memory Based Attachment Object + + """ + # Create our BytesIO object + self._data = io.BytesIO() + + if content is None: + # Empty; do nothing + pass + + elif isinstance(content, str): + content = content.encode(encoding) + if mimetype is None: + mimetype = 'text/plain' + + if not name: + # Generate a unique filename + name = str(uuid.uuid4()) + '.txt' + + elif not isinstance(content, bytes): + raise TypeError( + 'Provided content for memory attachment is invalid') + + # Store our content + if content: + self._data.write(content) + + if mimetype is None: + # Default mimetype + mimetype = 'application/octet-stream' + + if not name: + # Generate a unique filename + name = str(uuid.uuid4()) + '.dat' + + # Initialize our base object + super().__init__(name=name, mimetype=mimetype, **kwargs) + + return + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any URL parameters + params = { + 'mime': self._mimetype, + } + + return 'memory://{name}?{params}'.format( + name=self.quote(self._name), + params=self.urlencode(params, safe='/') + ) + + def open(self, *args, **kwargs): + """ + return our memory object + """ + # Return our object + self._data.seek(0, 0) + return self._data + + def __enter__(self): + """ + support with clause + """ + # Return our object + self._data.seek(0, 0) + return self._data + + def download(self, **kwargs): + """ + Handle memory download() call + """ + + if self.location == ContentLocation.INACCESSIBLE: + # our content is inaccessible + return False + + if self.max_file_size > 0 and len(self) > self.max_file_size: + # The content to attach is to large + self.logger.error( + 'Content exceeds allowable maximum memory size ' + '({}KB): {}'.format( + int(self.max_file_size / 1024), self.url(privacy=True))) + + # Return False (signifying a failure) + return False + + return True + + def base64(self, encoding='ascii'): + """ + We need to over-ride this since the base64 sub-library seems to close + our file descriptor making it no longer referencable. + """ + + if not self: + # We could not access the attachment + self.logger.error( + 'Could not access attachment {}.'.format( + self.url(privacy=True))) + raise exception.AppriseFileNotFound("Attachment Missing") + self._data.seek(0, 0) + + return base64.b64encode(self._data.read()).decode(encoding) \ + if encoding else base64.b64encode(self._data.read()) + + def invalidate(self): + """ + Removes data + """ + self._data.truncate(0) + return + + def exists(self): + """ + over-ride exists() call + """ + size = len(self) + return True if self.location != ContentLocation.INACCESSIBLE \ + and size > 0 and ( + self.max_file_size <= 0 or + (self.max_file_size > 0 and size <= self.max_file_size)) \ + else False + + @staticmethod + def parse_url(url): + """ + Parses the URL so that we can handle all different file paths + and return it as our path object + + """ + + results = AttachBase.parse_url(url, verify_host=False) + if not results: + # We're done early; it's not a good URL + return results + + if 'name' not in results: + # Allow fall-back to be from URL + match = re.match(r'memory://(?P[^?]+)(\?.*)?', url, re.I) + if match: + # Store our filename only (ignore any defined paths) + results['name'] = \ + os.path.basename(AttachMemory.unquote(match.group('path'))) + return results + + @property + def path(self): + """ + return the filename + """ + if not self.exists(): + # we could not obtain our path + return None + + return self._name + + def __len__(self): + """ + Returns the size of he memory attachment + + """ + return self._data.getbuffer().nbytes + + def __bool__(self): + """ + Allows the Apprise object to be wrapped in an based 'if statement'. + True is returned if our content was downloaded correctly. + """ + + return self.exists() diff --git a/libs/apprise/cli.py b/libs/apprise/cli.py index 11a6cbc2b..4072a645b 100644 --- a/libs/apprise/cli.py +++ b/libs/apprise/cli.py @@ -27,26 +27,28 @@ # POSSIBILITY OF SUCH DAMAGE. import click +import textwrap import logging import platform import sys import os +import shutil import re from os.path import isfile from os.path import exists -from os.path import expanduser -from os.path import expandvars -from . import NotifyType -from . import NotifyFormat from . import Apprise from . import AppriseAsset from . import AppriseConfig +from . import PersistentStore -from .utils import parse_list +from .utils.parse import parse_list +from .utils.disk import dir_size, bytes_to_str, path_decode from .common import NOTIFY_TYPES from .common import NOTIFY_FORMATS +from .common import PERSISTENT_STORE_MODES +from .common import PersistentStoreState from .common import ContentLocation from .logger import logger @@ -59,6 +61,29 @@ # files. DEFAULT_RECURSION_DEPTH = 1 +# Default number of days to prune persistent storage +DEFAULT_STORAGE_PRUNE_DAYS = \ + int(os.environ.get('APPRISE_STORAGE_PRUNE_DAYS', 30)) + +# The default URL ID Length +DEFAULT_STORAGE_UID_LENGTH = \ + int(os.environ.get('APPRISE_STORAGE_UID_LENGTH', 8)) + +# Defines the envrionment variable to parse if defined. This is ONLY +# Referenced if: +# - No Configuration Files were found/loaded/specified +# - No URLs were provided directly into the CLI Call +DEFAULT_ENV_APPRISE_URLS = 'APPRISE_URLS' + +# Defines the over-ride path for the configuration files read +DEFAULT_ENV_APPRISE_CONFIG_PATH = 'APPRISE_CONFIG_PATH' + +# Defines the over-ride path for the plugins to load +DEFAULT_ENV_APPRISE_PLUGIN_PATH = 'APPRISE_PLUGIN_PATH' + +# Defines the over-ride path for the persistent storage +DEFAULT_ENV_APPRISE_STORAGE_PATH = 'APPRISE_STORAGE_PATH' + # Defines our click context settings adding -h to the additional options that # can be specified to get the help menu to come up CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help']) @@ -104,67 +129,94 @@ '/var/lib/apprise/plugins', ) +# +# Persistent Storage +# +DEFAULT_STORAGE_PATH = '~/.local/share/apprise/cache' + # Detect Windows if platform.system() == 'Windows': # Default Config Search Path for Windows Users DEFAULT_CONFIG_PATHS = ( - expandvars('%APPDATA%\\Apprise\\apprise'), - expandvars('%APPDATA%\\Apprise\\apprise.conf'), - expandvars('%APPDATA%\\Apprise\\apprise.yml'), - expandvars('%APPDATA%\\Apprise\\apprise.yaml'), - expandvars('%LOCALAPPDATA%\\Apprise\\apprise'), - expandvars('%LOCALAPPDATA%\\Apprise\\apprise.conf'), - expandvars('%LOCALAPPDATA%\\Apprise\\apprise.yml'), - expandvars('%LOCALAPPDATA%\\Apprise\\apprise.yaml'), + '%APPDATA%\\Apprise\\apprise', + '%APPDATA%\\Apprise\\apprise.conf', + '%APPDATA%\\Apprise\\apprise.yml', + '%APPDATA%\\Apprise\\apprise.yaml', + '%LOCALAPPDATA%\\Apprise\\apprise', + '%LOCALAPPDATA%\\Apprise\\apprise.conf', + '%LOCALAPPDATA%\\Apprise\\apprise.yml', + '%LOCALAPPDATA%\\Apprise\\apprise.yaml', # # Global Support # - # C:\ProgramData\Apprise\ - expandvars('%ALLUSERSPROFILE%\\Apprise\\apprise'), - expandvars('%ALLUSERSPROFILE%\\Apprise\\apprise.conf'), - expandvars('%ALLUSERSPROFILE%\\Apprise\\apprise.yml'), - expandvars('%ALLUSERSPROFILE%\\Apprise\\apprise.yaml'), + # C:\ProgramData\Apprise + '%ALLUSERSPROFILE%\\Apprise\\apprise', + '%ALLUSERSPROFILE%\\Apprise\\apprise.conf', + '%ALLUSERSPROFILE%\\Apprise\\apprise.yml', + '%ALLUSERSPROFILE%\\Apprise\\apprise.yaml', # C:\Program Files\Apprise - expandvars('%PROGRAMFILES%\\Apprise\\apprise'), - expandvars('%PROGRAMFILES%\\Apprise\\apprise.conf'), - expandvars('%PROGRAMFILES%\\Apprise\\apprise.yml'), - expandvars('%PROGRAMFILES%\\Apprise\\apprise.yaml'), + '%PROGRAMFILES%\\Apprise\\apprise', + '%PROGRAMFILES%\\Apprise\\apprise.conf', + '%PROGRAMFILES%\\Apprise\\apprise.yml', + '%PROGRAMFILES%\\Apprise\\apprise.yaml', # C:\Program Files\Common Files - expandvars('%COMMONPROGRAMFILES%\\Apprise\\apprise'), - expandvars('%COMMONPROGRAMFILES%\\Apprise\\apprise.conf'), - expandvars('%COMMONPROGRAMFILES%\\Apprise\\apprise.yml'), - expandvars('%COMMONPROGRAMFILES%\\Apprise\\apprise.yaml'), + '%COMMONPROGRAMFILES%\\Apprise\\apprise', + '%COMMONPROGRAMFILES%\\Apprise\\apprise.conf', + '%COMMONPROGRAMFILES%\\Apprise\\apprise.yml', + '%COMMONPROGRAMFILES%\\Apprise\\apprise.yaml', ) # Default Plugin Search Path for Windows Users DEFAULT_PLUGIN_PATHS = ( - expandvars('%APPDATA%\\Apprise\\plugins'), - expandvars('%LOCALAPPDATA%\\Apprise\\plugins'), + '%APPDATA%\\Apprise\\plugins', + '%LOCALAPPDATA%\\Apprise\\plugins', # # Global Support # # C:\ProgramData\Apprise\plugins - expandvars('%ALLUSERSPROFILE%\\Apprise\\plugins'), + '%ALLUSERSPROFILE%\\Apprise\\plugins', # C:\Program Files\Apprise\plugins - expandvars('%PROGRAMFILES%\\Apprise\\plugins'), + '%PROGRAMFILES%\\Apprise\\plugins', # C:\Program Files\Common Files - expandvars('%COMMONPROGRAMFILES%\\Apprise\\plugins'), + '%COMMONPROGRAMFILES%\\Apprise\\plugins', ) + # + # Persistent Storage + # + DEFAULT_STORAGE_PATH = '%APPDATA%/Apprise/cache' -def print_help_msg(command): - """ - Prints help message when -h or --help is specified. +class PersistentStorageMode: """ - with click.Context(command) as ctx: - click.echo(command.get_help(ctx)) + Persistent Storage Modes + """ + # List all detected configuration loaded + LIST = 'list' + + # Prune persistent storage based on age + PRUNE = 'prune' + + # Reset all (reguardless of age) + CLEAR = 'clear' + + +# Define the types in a list for validation purposes +PERSISTENT_STORAGE_MODES = ( + PersistentStorageMode.LIST, + PersistentStorageMode.PRUNE, + PersistentStorageMode.CLEAR, +) + +if os.environ.get('APPRISE_STORAGE_PATH', '').strip(): + # Over-ride Default Storage Path + DEFAULT_STORAGE_PATH = os.environ.get('APPRISE_STORAGE_PATH') def print_version_msg(): @@ -180,7 +232,113 @@ def print_version_msg(): click.echo('\n'.join(result)) -@click.command(context_settings=CONTEXT_SETTINGS) +class CustomHelpCommand(click.Command): + def format_help(self, ctx, formatter): + formatter.write_text('Usage:') + formatter.write_text( + ' apprise [OPTIONS] [APPRISE_URL [APPRISE_URL2 [APPRISE_URL3]]]') + formatter.write_text( + ' apprise storage [OPTIONS] [ACTION] [UID1 [UID2 [UID3]]]') + + # Custom help message + formatter.write_text('') + content = ( + 'Send a notification to all of the specified servers ' + 'identified by their URLs', + 'the content provided within the title, body and ' + 'notification-type.', + '', + 'For a list of all of the supported services and information on ' + 'how to use ', + 'them, check out at https://github.com/caronc/apprise') + + for line in content: + formatter.write_text(line) + + # Display options and arguments in the default format + self.format_options(ctx, formatter) + self.format_epilog(ctx, formatter) + + # Custom 'Actions:' section after the 'Options:' + formatter.write_text('') + formatter.write_text('Actions:') + + actions = [( + 'storage', 'Access the persistent storage disk administration', + [( + 'list', + 'List all URL IDs associated with detected URL(s). ' + 'This is also the default action ran if nothing is provided', + ), ( + 'prune', + 'Eliminates stale entries found based on ' + '--storage-prune-days (-SPD)', + ), ( + 'clean', + 'Removes any persistent data created by Apprise', + )], + )] + + # + # Some variables + # + + # actions are indented this many spaces + # sub actions double this value + action_indent = 2 + + # label padding (for alignment) + action_label_width = 10 + + space = ' ' + space_re = re.compile(r'\r*\n') + cols = 80 + indent = 10 + + # Format each action and its subactions + for action, description, sub_actions in actions: + # Our action indent + ai = ' ' * action_indent + # Format the main action description + formatted_description = space_re.split(textwrap.fill( + description, width=(cols - indent - action_indent), + initial_indent=space * indent, + subsequent_indent=space * indent)) + for no, line in enumerate(formatted_description): + if not no: + formatter.write_text( + f'{ai}{action:<{action_label_width}}{line}') + + else: # pragma: no cover + # Note: no branch is set intentionally since this is not + # tested since in 2024.08.13 when this was set up + # it never entered this area of the code. But we + # know it works because we repeat this process with + # our sub-options below + formatter.write_text( + f'{ai}{space:<{action_label_width}}{line}') + + # Format each subaction + ai = ' ' * (action_indent * 2) + for action, description in sub_actions: + formatted_description = space_re.split(textwrap.fill( + description, width=(cols - indent - (action_indent * 3)), + initial_indent=space * (indent - action_indent), + subsequent_indent=space * (indent - action_indent))) + + for no, line in enumerate(formatted_description): + if not no: + formatter.write_text( + f'{ai}{action:<{action_label_width}}{line}') + else: + formatter.write_text( + f'{ai}{space:<{action_label_width}}{line}') + + # Include any epilog or additional text + self.format_epilog(ctx, formatter) + + +@click.command(context_settings=CONTEXT_SETTINGS, cls=CustomHelpCommand) @click.option('--body', '-b', default=None, type=str, help='Specify the message body. If no body is specified then ' 'content is read from .') @@ -188,25 +346,47 @@ def print_version_msg(): help='Specify the message title. This field is complete ' 'optional.') @click.option('--plugin-path', '-P', default=None, type=str, multiple=True, - metavar='PLUGIN_PATH', + metavar='PATH', help='Specify one or more plugin paths to scan.') +@click.option('--storage-path', '-S', default=DEFAULT_STORAGE_PATH, type=str, + metavar='PATH', + help='Specify the path to the persistent storage location ' + '(default={}).'.format(DEFAULT_STORAGE_PATH)) +@click.option('--storage-prune-days', '-SPD', + default=DEFAULT_STORAGE_PRUNE_DAYS, type=int, + help='Define the number of days the storage prune ' + 'should run using. Setting this to zero (0) will eliminate ' + 'all accumulated content. By default this value is {} days.' + .format(DEFAULT_STORAGE_PRUNE_DAYS)) +@click.option('--storage-uid-length', '-SUL', + default=DEFAULT_STORAGE_UID_LENGTH, type=int, + help='Define the number of unique characters to store persistent' + 'cache in. By default this value is {} characters.' + .format(DEFAULT_STORAGE_UID_LENGTH)) +@click.option('--storage-mode', '-SM', default=PERSISTENT_STORE_MODES[0], + type=str, metavar='MODE', + help='Specify the persistent storage operational mode ' + '(default={}). Possible values are "{}", and "{}".'.format( + PERSISTENT_STORE_MODES[0], '", "'.join( + PERSISTENT_STORE_MODES[:-1]), + PERSISTENT_STORE_MODES[-1])) @click.option('--config', '-c', default=None, type=str, multiple=True, metavar='CONFIG_URL', help='Specify one or more configuration locations.') @click.option('--attach', '-a', default=None, type=str, multiple=True, metavar='ATTACHMENT_URL', help='Specify one or more attachment.') -@click.option('--notification-type', '-n', default=NotifyType.INFO, type=str, +@click.option('--notification-type', '-n', default=NOTIFY_TYPES[0], type=str, metavar='TYPE', help='Specify the message type (default={}). ' 'Possible values are "{}", and "{}".'.format( - NotifyType.INFO, '", "'.join(NOTIFY_TYPES[:-1]), + NOTIFY_TYPES[0], '", "'.join(NOTIFY_TYPES[:-1]), NOTIFY_TYPES[-1])) -@click.option('--input-format', '-i', default=NotifyFormat.TEXT, type=str, +@click.option('--input-format', '-i', default=NOTIFY_FORMATS[0], type=str, metavar='FORMAT', help='Specify the message input format (default={}). ' 'Possible values are "{}", and "{}".'.format( - NotifyFormat.TEXT, '", "'.join(NOTIFY_FORMATS[:-1]), + NOTIFY_FORMATS[0], '", "'.join(NOTIFY_FORMATS[:-1]), NOTIFY_FORMATS[-1])) @click.option('--theme', '-T', default='default', type=str, metavar='THEME', help='Specify the default theme.') @@ -241,10 +421,12 @@ def print_version_msg(): help='Display the apprise version and exit.') @click.argument('urls', nargs=-1, metavar='SERVER_URL [SERVER_URL2 [SERVER_URL3]]',) -def main(body, title, config, attach, urls, notification_type, theme, tag, +@click.pass_context +def main(ctx, body, title, config, attach, urls, notification_type, theme, tag, input_format, dry_run, recursion_depth, verbose, disable_async, - details, interpret_escapes, interpret_emojis, plugin_path, debug, - version): + details, interpret_escapes, interpret_emojis, plugin_path, + storage_path, storage_mode, storage_prune_days, storage_uid_length, + debug, version): """ Send a notification to all of the specified servers identified by their URLs the content provided within the title, body and notification-type. @@ -253,7 +435,7 @@ def main(body, title, config, attach, urls, notification_type, theme, tag, use them, check out at https://github.com/caronc/apprise """ # Note: Click ignores the return values of functions it wraps, If you - # want to return a specific error code, you must call sys.exit() + # want to return a specific error code, you must call ctx.exit() # as you will see below. debug = True if debug else False @@ -297,32 +479,96 @@ def main(body, title, config, attach, urls, notification_type, theme, tag, if version: print_version_msg() - sys.exit(0) + ctx.exit(0) # Simple Error Checking notification_type = notification_type.strip().lower() if notification_type not in NOTIFY_TYPES: - logger.error( + click.echo( 'The --notification-type (-n) value of {} is not supported.' .format(notification_type)) + click.echo("Try 'apprise --help' for more information.") # 2 is the same exit code returned by Click if there is a parameter # issue. For consistency, we also return a 2 - sys.exit(2) + ctx.exit(2) input_format = input_format.strip().lower() if input_format not in NOTIFY_FORMATS: - logger.error( + click.echo( 'The --input-format (-i) value of {} is not supported.' .format(input_format)) + click.echo("Try 'apprise --help' for more information.") # 2 is the same exit code returned by Click if there is a parameter # issue. For consistency, we also return a 2 - sys.exit(2) + ctx.exit(2) + + storage_mode = storage_mode.strip().lower() + if storage_mode not in PERSISTENT_STORE_MODES: + click.echo( + 'The --storage-mode (-SM) value of {} is not supported.' + .format(storage_mode)) + click.echo("Try 'apprise --help' for more information.") + # 2 is the same exit code returned by Click if there is a parameter + # issue. For consistency, we also return a 2 + ctx.exit(2) + + # + # Apply Environment Over-rides if defined + # + _config_paths = DEFAULT_CONFIG_PATHS + if 'APPRISE_CONFIG' in os.environ: + # Deprecate (this was from previous versions of Apprise <= 1.9.1) + logger.deprecate( + 'APPRISE_CONFIG environment variable has been changed to ' + f'{DEFAULT_ENV_APPRISE_CONFIG_PATH}') + logger.debug( + 'Loading provided APPRISE_CONFIG (deprecated) environment ' + 'variable') + _config_paths = (os.environ.get('APPRISE_CONFIG', '').strip(), ) + + elif DEFAULT_ENV_APPRISE_CONFIG_PATH in os.environ: + logger.debug( + f'Loading provided {DEFAULT_ENV_APPRISE_CONFIG_PATH} ' + 'environment variable') + _config_paths = re.split( + r'[\r\n;]+', os.environ.get( + DEFAULT_ENV_APPRISE_CONFIG_PATH).strip()) + + _plugin_paths = DEFAULT_PLUGIN_PATHS + if DEFAULT_ENV_APPRISE_PLUGIN_PATH in os.environ: + logger.debug( + f'Loading provided {DEFAULT_ENV_APPRISE_PLUGIN_PATH} environment ' + 'variable') + _plugin_paths = re.split( + r'[\r\n;]+', os.environ.get( + DEFAULT_ENV_APPRISE_PLUGIN_PATH).strip()) + + if DEFAULT_ENV_APPRISE_STORAGE_PATH in os.environ: + logger.debug( + f'Loading provided {DEFAULT_ENV_APPRISE_STORAGE_PATH} environment ' + 'variable') + storage_path = \ + os.environ.get(DEFAULT_ENV_APPRISE_STORAGE_PATH).strip() + + # + # Continue with initialization process + # + + # Prepare a default set of plugin paths to scan; anything specified + # on the CLI always trumps + plugin_paths = \ + [path for path in _plugin_paths if exists(path_decode(path))] \ + if not plugin_path else plugin_path - if not plugin_path: - # Prepare a default set of plugin path - plugin_path = \ - next((path for path in DEFAULT_PLUGIN_PATHS - if exists(expanduser(path))), None) + if storage_uid_length < 2: + click.echo( + 'The --storage-uid-length (-SUL) value can not be lower ' + 'then two (2).') + click.echo("Try 'apprise --help' for more information.") + + # 2 is the same exit code returned by Click if there is a + # parameter issue. For consistency, we also return a 2 + ctx.exit(2) # Prepare our asset asset = AppriseAsset( @@ -345,12 +591,24 @@ def main(body, title, config, attach, urls, notification_type, theme, tag, async_mode=disable_async is not True, # Load our plugins - plugin_paths=plugin_path, + plugin_paths=plugin_paths, + + # Load our persistent storage path + storage_path=path_decode(storage_path), + + # Our storage URL ID Length + storage_idlen=storage_uid_length, + + # Define if we flush to disk as soon as possible or not when required + storage_mode=storage_mode ) # Create our Apprise object a = Apprise(asset=asset, debug=debug, location=ContentLocation.LOCAL) + # Track if we are performing a storage action + storage_action = True if urls and 'storage'.startswith(urls[0]) else False + if details: # Print details and exit results = a.details(show_requirements=True, show_disabled=True) @@ -429,17 +687,16 @@ def main(body, title, config, attach, urls, notification_type, theme, tag, # new line padding between entries click.echo() - sys.exit(0) + ctx.exit(0) # end if details() # The priorities of what is accepted are parsed in order below: # 1. URLs by command line # 2. Configuration by command line # 3. URLs by environment variable: APPRISE_URLS - # 4. Configuration by environment variable: APPRISE_CONFIG - # 5. Default Configuration File(s) (if found) + # 4. Default Configuration File(s) # - if urls: + elif urls and not storage_action: if tag: # Ignore any tags specified logger.warning( @@ -462,8 +719,10 @@ def main(body, title, config, attach, urls, notification_type, theme, tag, a.add(AppriseConfig( paths=config, asset=asset, recursion=recursion_depth)) - elif os.environ.get('APPRISE_URLS', '').strip(): - logger.debug('Loading provided APPRISE_URLS environment variable') + elif os.environ.get(DEFAULT_ENV_APPRISE_URLS, '').strip(): + logger.debug( + f'Loading provided {DEFAULT_ENV_APPRISE_URLS} environment ' + 'variable') if tag: # Ignore any tags specified logger.warning( @@ -471,32 +730,153 @@ def main(body, title, config, attach, urls, notification_type, theme, tag, tag = None # Attempt to use our APPRISE_URLS environment variable (if populated) - a.add(os.environ['APPRISE_URLS'].strip()) - - elif os.environ.get('APPRISE_CONFIG', '').strip(): - logger.debug('Loading provided APPRISE_CONFIG environment variable') - # Fall back to config environment variable (if populated) - a.add(AppriseConfig( - paths=os.environ['APPRISE_CONFIG'].strip(), - asset=asset, recursion=recursion_depth)) + a.add(os.environ[DEFAULT_ENV_APPRISE_URLS].strip()) else: # Load default configuration a.add(AppriseConfig( - paths=[f for f in DEFAULT_CONFIG_PATHS if isfile(expanduser(f))], + paths=[f for f in _config_paths if isfile(path_decode(f))], asset=asset, recursion=recursion_depth)) - if len(a) == 0 and not urls: - logger.error( + if not dry_run and not (a or storage_action): + click.echo( 'You must specify at least one server URL or populated ' 'configuration file.') - print_help_msg(main) - sys.exit(1) + click.echo("Try 'apprise --help' for more information.") + ctx.exit(1) # each --tag entry comprises of a comma separated 'and' list # we or each of of the --tag and sets specified. tags = None if not tag else [parse_list(t) for t in tag] + # Determine if we're dealing with URLs or url_ids based on the first + # entry provided. + if storage_action: + # + # Storage Mode + # - urls are now to be interpreted as best matching namespaces + # + if storage_prune_days < 0: + click.echo( + 'The --storage-prune-days (-SPD) value can not be lower ' + 'then zero (0).') + click.echo("Try 'apprise --help' for more information.") + + # 2 is the same exit code returned by Click if there is a + # parameter issue. For consistency, we also return a 2 + ctx.exit(2) + + # Number of columns to assume in the terminal. In future, maybe this + # can be detected and made dynamic. The actual column count is 80, but + # 5 characters are already reserved for the counter on the left + (columns, _) = shutil.get_terminal_size(fallback=(80, 24)) + + # Pop 'storage' off of the head of our list + filter_uids = urls[1:] + + action = PERSISTENT_STORAGE_MODES[0] + if filter_uids: + _action = next( # pragma: no branch + (a for a in PERSISTENT_STORAGE_MODES + if a.startswith(filter_uids[0])), None) + + if _action: + # pop 'action' off the head of our list + filter_uids = filter_uids[1:] + action = _action + + # Get our detected URL IDs + uids = {} + for plugin in (a if not tags else a.find(tag=tags)): + _id = plugin.url_id() + if not _id: + continue + + if filter_uids and next( + (False for n in filter_uids if _id.startswith(n)), True): + continue + + if _id not in uids: + uids[_id] = { + 'plugins': [plugin], + 'state': PersistentStoreState.UNUSED, + 'size': 0, + } + + else: + # It's possible to have more then one URL point to the same + # location (thus match against the same url id more then once + uids[_id]['plugins'].append(plugin) + + if action == PersistentStorageMode.LIST: + detected_uid = PersistentStore.disk_scan( + # Use our asset path as it has already been properly parsed + path=asset.storage_path, + + # Provide filter if specified + namespace=filter_uids, + ) + for _id in detected_uid: + size, _ = dir_size(os.path.join(asset.storage_path, _id)) + if _id in uids: + uids[_id]['state'] = PersistentStoreState.ACTIVE + uids[_id]['size'] = size + + elif not tags: + uids[_id] = { + 'plugins': [], + # No cross reference (wasted space?) + 'state': PersistentStoreState.STALE, + # Acquire disk space + 'size': size, + } + + for idx, (uid, meta) in enumerate(uids.items()): + fg = "green" \ + if meta['state'] == PersistentStoreState.ACTIVE else ( + "red" + if meta['state'] == PersistentStoreState.STALE else + "white") + + if idx > 0: + # New line + click.echo() + click.echo("{: 4d}. ".format(idx + 1), nl=False) + click.echo(click.style("{:<52} {:<8} {}".format( + uid, bytes_to_str(meta['size']), meta['state']), + fg=fg, bold=True)) + + for entry in meta['plugins']: + url = entry.url(privacy=True) + click.echo("{:>7} {}".format( + '-', + url if len(url) <= (columns - 8) else '{}...'.format( + url[:columns - 11]))) + + if entry.tags: + click.echo("{:>10}: {}".format( + 'tags', ', '.join(entry.tags))) + + else: # PersistentStorageMode.PRUNE or PersistentStorageMode.CLEAR + if action == PersistentStorageMode.CLEAR: + storage_prune_days = 0 + + # clean up storage + results = PersistentStore.disk_prune( + # Use our asset path as it has already been properly parsed + path=asset.storage_path, + # Provide our namespaces if they exist + namespace=None if not filter_uids else filter_uids, + # Convert expiry from days to seconds + expires=storage_prune_days * 60 * 60 * 24, + action=not dry_run) + + ctx.exit(0) + # end if disk_prune() + + ctx.exit(0) + # end if storage() + if not dry_run: if body is None: logger.trace('No --body (-b) specified; reading from stdin') @@ -508,10 +888,10 @@ def main(body, title, config, attach, urls, notification_type, theme, tag, body=body, title=title, notify_type=notification_type, tag=tags, attach=attach) else: - # Number of rows to assume in the terminal. In future, maybe this can - # be detected and made dynamic. The actual row count is 80, but 5 - # characters are already reserved for the counter on the left - rows = 75 + # Number of columns to assume in the terminal. In future, maybe this + # can be detected and made dynamic. The actual column count is 80, but + # 5 characters are already reserved for the counter on the left + (columns, _) = shutil.get_terminal_size(fallback=(80, 24)) # Initialize our URL response; This is populated within the for/loop # below; but plays a factor at the end when we need to determine if @@ -520,11 +900,18 @@ def main(body, title, config, attach, urls, notification_type, theme, tag, for idx, server in enumerate(a.find(tag=tags)): url = server.url(privacy=True) - click.echo("{: 3d}. {}".format( + click.echo("{: 4d}. {}".format( idx + 1, - url if len(url) <= rows else '{}...'.format(url[:rows - 3]))) + url if len(url) <= (columns - 8) else '{}...'.format( + url[:columns - 9]))) + + # Share our URL ID + click.echo("{:>10}: {}".format( + 'uid', '- n/a -' if not server.url_id() + else server.url_id())) + if server.tags: - click.echo("{} - {}".format(' ' * 5, ', '.join(server.tags))) + click.echo("{:>10}: {}".format('tags', ', '.join(server.tags))) # Initialize a default response of nothing matched, otherwise # if we matched at least one entry, we can return True @@ -537,11 +924,11 @@ def main(body, title, config, attach, urls, notification_type, theme, tag, # Exit code 3 is used since Click uses exit code 2 if there is an # error with the parameters specified - sys.exit(3) + ctx.exit(3) elif result is False: # At least 1 notification service failed to send - sys.exit(1) + ctx.exit(1) # else: We're good! - sys.exit(0) + ctx.exit(0) diff --git a/libs/apprise/common.py b/libs/apprise/common.py index d6fe2cd0d..a8e9cd34e 100644 --- a/libs/apprise/common.py +++ b/libs/apprise/common.py @@ -187,6 +187,42 @@ class ContentLocation: ContentLocation.INACCESSIBLE, ) + +class PersistentStoreMode: + # Allow persistent storage; write on demand + AUTO = 'auto' + + # Always flush every change to disk after it's saved. This has higher i/o + # but enforces disk reflects what was set immediately + FLUSH = 'flush' + + # memory based store only + MEMORY = 'memory' + + +PERSISTENT_STORE_MODES = ( + PersistentStoreMode.AUTO, + PersistentStoreMode.FLUSH, + PersistentStoreMode.MEMORY, +) + + +class PersistentStoreState: + """ + Defines the persistent states describing what has been cached + """ + # Persistent Directory is actively cross-referenced against a matching URL + ACTIVE = 'active' + + # Persistent Directory is no longer being used or has no cross-reference + STALE = 'stale' + + # Persistent Directory is not utilizing any disk space at all, however + # it potentially could if the plugin it successfully cross-references + # is utilized + UNUSED = 'unused' + + # This is a reserved tag that is automatically assigned to every # Notification Plugin MATCH_ALL_TAG = 'all' diff --git a/libs/apprise/config/__init__.py b/libs/apprise/config/__init__.py index efbace687..24957e88e 100644 --- a/libs/apprise/config/__init__.py +++ b/libs/apprise/config/__init__.py @@ -27,8 +27,8 @@ # POSSIBILITY OF SUCH DAMAGE. # Used for testing -from .ConfigBase import ConfigBase -from ..ConfigurationManager import ConfigurationManager +from .base import ConfigBase +from ..manager_config import ConfigurationManager # Initalize our Config Manager Singleton C_MGR = ConfigurationManager() @@ -36,4 +36,5 @@ __all__ = [ # Reference 'ConfigBase', + 'ConfigurationManager', ] diff --git a/libs/apprise/config/ConfigBase.py b/libs/apprise/config/base.py similarity index 99% rename from libs/apprise/config/ConfigBase.py rename to libs/apprise/config/base.py index 32e1bde34..03a4423a9 100644 --- a/libs/apprise/config/ConfigBase.py +++ b/libs/apprise/config/base.py @@ -33,15 +33,12 @@ from .. import plugins from .. import common -from ..AppriseAsset import AppriseAsset -from ..URLBase import URLBase -from ..ConfigurationManager import ConfigurationManager -from ..utils import GET_SCHEMA_RE -from ..utils import parse_list -from ..utils import parse_bool -from ..utils import parse_urls -from ..utils import cwe312_url -from ..NotificationManager import NotificationManager +from ..asset import AppriseAsset +from ..url import URLBase +from ..utils.parse import GET_SCHEMA_RE, parse_list, parse_bool, parse_urls +from ..utils.cwe312 import cwe312_url +from ..manager_config import ConfigurationManager +from ..manager_plugins import NotificationManager # Test whether token is valid or not VALID_TOKEN = re.compile( diff --git a/libs/apprise/config/ConfigBase.pyi b/libs/apprise/config/base.pyi similarity index 100% rename from libs/apprise/config/ConfigBase.pyi rename to libs/apprise/config/base.pyi diff --git a/libs/apprise/config/ConfigFile.py b/libs/apprise/config/file.py similarity index 95% rename from libs/apprise/config/ConfigFile.py rename to libs/apprise/config/file.py index 172d699f8..89224e2ea 100644 --- a/libs/apprise/config/ConfigFile.py +++ b/libs/apprise/config/file.py @@ -28,10 +28,11 @@ import re import os -from .ConfigBase import ConfigBase +from .base import ConfigBase +from ..utils.disk import path_decode from ..common import ConfigFormat from ..common import ContentIncludeMode -from ..AppriseLocale import gettext_lazy as _ +from ..locale import gettext_lazy as _ class ConfigFile(ConfigBase): @@ -59,7 +60,10 @@ def __init__(self, path, **kwargs): super().__init__(**kwargs) # Store our file path as it was set - self.path = os.path.abspath(os.path.expanduser(path)) + self.path = path_decode(path) + + # Track the file as it was saved + self.__original_path = os.path.normpath(path) # Update the config path to be relative to our file we just loaded self.config_path = os.path.dirname(self.path) @@ -89,7 +93,7 @@ def url(self, privacy=False, *args, **kwargs): params['format'] = self.config_format return 'file://{path}{params}'.format( - path=self.quote(self.path), + path=self.quote(self.__original_path), params='?{}'.format(self.urlencode(params)) if params else '', ) diff --git a/libs/apprise/config/ConfigHTTP.py b/libs/apprise/config/http.py similarity index 98% rename from libs/apprise/config/ConfigHTTP.py rename to libs/apprise/config/http.py index f6faba8d4..2e2ba299b 100644 --- a/libs/apprise/config/ConfigHTTP.py +++ b/libs/apprise/config/http.py @@ -28,11 +28,11 @@ import re import requests -from .ConfigBase import ConfigBase +from .base import ConfigBase from ..common import ConfigFormat from ..common import ContentIncludeMode -from ..URLBase import PrivacyMode -from ..AppriseLocale import gettext_lazy as _ +from ..url import PrivacyMode +from ..locale import gettext_lazy as _ # Support YAML formats # text/yaml diff --git a/libs/apprise/config/ConfigMemory.py b/libs/apprise/config/memory.py similarity index 97% rename from libs/apprise/config/ConfigMemory.py rename to libs/apprise/config/memory.py index 413956dfc..181d76236 100644 --- a/libs/apprise/config/ConfigMemory.py +++ b/libs/apprise/config/memory.py @@ -26,8 +26,8 @@ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. -from .ConfigBase import ConfigBase -from ..AppriseLocale import gettext_lazy as _ +from .base import ConfigBase +from ..locale import gettext_lazy as _ class ConfigMemory(ConfigBase): diff --git a/libs/apprise/conversion.py b/libs/apprise/conversion.py index 4d5632f59..366fe8e3e 100644 --- a/libs/apprise/conversion.py +++ b/libs/apprise/conversion.py @@ -29,7 +29,7 @@ import re from markdown import markdown from .common import NotifyFormat -from .URLBase import URLBase +from .url import URLBase from html.parser import HTMLParser @@ -180,8 +180,10 @@ def handle_starttag(self, tag, attrs): self._result.append('\n') elif tag == 'hr': - if self._result: + if self._result and isinstance(self._result[-1], str): self._result[-1] = self._result[-1].rstrip(' ') + else: + pass self._result.append('\n---\n') diff --git a/libs/apprise/decorators/CustomNotifyPlugin.py b/libs/apprise/decorators/base.py similarity index 95% rename from libs/apprise/decorators/CustomNotifyPlugin.py rename to libs/apprise/decorators/base.py index eb5f17b78..e7cf7b666 100644 --- a/libs/apprise/decorators/CustomNotifyPlugin.py +++ b/libs/apprise/decorators/base.py @@ -27,12 +27,10 @@ # POSSIBILITY OF SUCH DAMAGE.USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. -from ..plugins.NotifyBase import NotifyBase -from ..NotificationManager import NotificationManager -from ..utils import URL_DETAILS_RE -from ..utils import parse_url -from ..utils import url_assembly -from ..utils import dict_full_update +from ..plugins.base import NotifyBase +from ..manager_plugins import NotificationManager +from ..utils.parse import URL_DETAILS_RE, parse_url, url_assembly +from ..utils.logic import dict_full_update from .. import common from ..logger import logger import inspect @@ -55,6 +53,12 @@ class CustomNotifyPlugin(NotifyBase): # should be treated differently. category = 'custom' + # Support Attachments + attachment_support = True + + # Allow persistent storage support + storage_mode = common.PersistentStoreMode.AUTO + # Define object templates templates = ( '{schema}://', diff --git a/libs/apprise/decorators/notify.py b/libs/apprise/decorators/notify.py index 2dd5f5099..892c3adfe 100644 --- a/libs/apprise/decorators/notify.py +++ b/libs/apprise/decorators/notify.py @@ -26,7 +26,7 @@ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. -from .CustomNotifyPlugin import CustomNotifyPlugin +from .base import CustomNotifyPlugin def notify(on, name=None): diff --git a/libs/apprise/exception.py b/libs/apprise/exception.py new file mode 100644 index 000000000..a11f28248 --- /dev/null +++ b/libs/apprise/exception.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2024, Chris Caron +# +# 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. +import errno + + +class AppriseException(Exception): + """ + Base Apprise Exception Class + """ + def __init__(self, message, error_code=0): + super().__init__(message) + self.error_code = error_code + + +class ApprisePluginException(AppriseException): + """ + Class object for handling exceptions raised from within a plugin + """ + def __init__(self, message, error_code=600): + super().__init__(message, error_code=error_code) + + +class AppriseDiskIOError(AppriseException): + """ + Thrown when an disk i/o error occurs + """ + def __init__(self, message, error_code=errno.EIO): + super().__init__(message, error_code=error_code) + + +class AppriseFileNotFound(AppriseDiskIOError, FileNotFoundError): + """ + Thrown when a persistent write occured in MEMORY mode + """ + def __init__(self, message): + super().__init__(message, error_code=errno.ENOENT) diff --git a/libs/apprise/i18n/en/LC_MESSAGES/apprise.mo b/libs/apprise/i18n/en/LC_MESSAGES/apprise.mo index 1d22b89a6..8eafd6512 100644 Binary files a/libs/apprise/i18n/en/LC_MESSAGES/apprise.mo and b/libs/apprise/i18n/en/LC_MESSAGES/apprise.mo differ diff --git a/libs/apprise/AppriseLocale.py b/libs/apprise/locale.py similarity index 100% rename from libs/apprise/AppriseLocale.py rename to libs/apprise/locale.py diff --git a/libs/apprise/manager.py b/libs/apprise/manager.py index c2b715d4f..afc9c1a32 100644 --- a/libs/apprise/manager.py +++ b/libs/apprise/manager.py @@ -33,9 +33,10 @@ import hashlib import inspect import threading -from .utils import import_module -from .utils import Singleton -from .utils import parse_list +from .utils.module import import_module +from .utils.singleton import Singleton +from .utils.parse import parse_list +from .utils.disk import path_decode from os.path import dirname from os.path import abspath from os.path import join @@ -61,6 +62,9 @@ class PluginManager(metaclass=Singleton): # The module path to scan module_path = join(abspath(dirname(__file__)), _id) + # For filtering our result when scanning a module + module_filter_re = re.compile(r'^(?P((?!_)[A-Za-z0-9]+))$') + # thread safe loading _lock = threading.Lock() @@ -177,7 +181,7 @@ def load_modules(self, path=None, name=None, force=False): # The .py extension is optional as we support loading directories # too module_re = re.compile( - r'^(?P' + self.fname_prefix + r'[a-z0-9]+)(\.py)?$', + r'^(?P(?!base|_)[a-z0-9_]+)(\.py)?$', re.I) t_start = time.time() @@ -188,10 +192,6 @@ def load_modules(self, path=None, name=None, force=False): # keep going continue - elif match.group('name') == f'{self.fname_prefix}Base': - # keep going - continue - # Store our notification/plugin name: module_name = match.group('name') module_pyname = '{}.{}'.format(module_name_prefix, module_name) @@ -216,7 +216,49 @@ def load_modules(self, path=None, name=None, force=False): # logging found in import_module and not needed here continue - if not hasattr(module, module_name): + module_class = None + for m_class in [obj for obj in dir(module) + if self.module_filter_re.match(obj)]: + # Get our plugin + plugin = getattr(module, m_class) + if not hasattr(plugin, 'app_id'): + # Filter out non-notification modules + logger.trace( + "(%s.%s) import failed; no app_id defined in %s", + self.name, m_class, os.path.join(module_path, f)) + continue + + # Add our plugin name to our module map + self._module_map[module_name] = { + 'plugin': set([plugin]), + 'module': module, + 'path': '{}.{}'.format( + module_name_prefix, module_name), + 'native': True, + } + + fn = getattr(plugin, 'schemas', None) + schemas = set([]) if not callable(fn) else fn(plugin) + + # map our schema to our plugin + for schema in schemas: + if schema in self._schema_map: + logger.error( + "{} schema ({}) mismatch detected -" + ' {} already maps to {}' + .format(self.name, schema, + self._schema_map[schema], + plugin)) + continue + + # Assign plugin + self._schema_map[schema] = plugin + + # Store our class + module_class = m_class + break + + if not module_class: # Not a library we can load as it doesn't follow the simple # rule that the class must bear the same name as the # notification file itself. @@ -226,38 +268,6 @@ def load_modules(self, path=None, name=None, force=False): self.name, module_name, os.path.join(module_path, f)) continue - # Get our plugin - plugin = getattr(module, module_name) - if not hasattr(plugin, 'app_id'): - # Filter out non-notification modules - logger.trace( - "(%s) import failed; no app_id defined in %s", - self.name, module_name, os.path.join(module_path, f)) - continue - - # Add our plugin name to our module map - self._module_map[module_name] = { - 'plugin': set([plugin]), - 'module': module, - 'path': '{}.{}'.format(module_name_prefix, module_name), - 'native': True, - } - - fn = getattr(plugin, 'schemas', None) - schemas = set([]) if not callable(fn) else fn(plugin) - - # map our schema to our plugin - for schema in schemas: - if schema in self._schema_map: - logger.error( - "{} schema ({}) mismatch detected - {} to {}" - .format(self.name, schema, self._schema_map, - plugin)) - continue - - # Assign plugin - self._schema_map[schema] = plugin - logger.trace( '{} {} loaded in {:.6f}s'.format( self.name, module_name, (time.time() - tl_start))) @@ -366,7 +376,7 @@ def _import_module(path): return for _path in paths: - path = os.path.abspath(os.path.expanduser(_path)) + path = path_decode(_path) if (cache and path in self._paths_previously_scanned) \ or not os.path.exists(path): # We're done as we've already scanned this diff --git a/libs/apprise/AttachmentManager.py b/libs/apprise/manager_attachment.py similarity index 93% rename from libs/apprise/AttachmentManager.py rename to libs/apprise/manager_attachment.py index d296a4996..d1288a943 100644 --- a/libs/apprise/AttachmentManager.py +++ b/libs/apprise/manager_attachment.py @@ -26,6 +26,7 @@ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. +import re from os.path import dirname from os.path import abspath from os.path import join @@ -52,3 +53,7 @@ class AttachmentManager(PluginManager): # The module path to scan module_path = join(abspath(dirname(__file__)), _id) + + # For filtering our result set + module_filter_re = re.compile( + r'^(?P' + fname_prefix + r'(?!Base)[A-Za-z0-9]+)$') diff --git a/libs/apprise/ConfigurationManager.py b/libs/apprise/manager_config.py similarity index 93% rename from libs/apprise/ConfigurationManager.py rename to libs/apprise/manager_config.py index 6696895b9..69a6bedb9 100644 --- a/libs/apprise/ConfigurationManager.py +++ b/libs/apprise/manager_config.py @@ -26,6 +26,7 @@ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. +import re from os.path import dirname from os.path import abspath from os.path import join @@ -52,3 +53,7 @@ class ConfigurationManager(PluginManager): # The module path to scan module_path = join(abspath(dirname(__file__)), _id) + + # For filtering our result set + module_filter_re = re.compile( + r'^(?P' + fname_prefix + r'(?!Base)[A-Za-z0-9]+)$') diff --git a/libs/apprise/NotificationManager.py b/libs/apprise/manager_plugins.py similarity index 92% rename from libs/apprise/NotificationManager.py rename to libs/apprise/manager_plugins.py index abbbdd203..74ed370ea 100644 --- a/libs/apprise/NotificationManager.py +++ b/libs/apprise/manager_plugins.py @@ -26,6 +26,7 @@ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. +import re from os.path import dirname from os.path import abspath from os.path import join @@ -52,3 +53,8 @@ class NotificationManager(PluginManager): # The module path to scan module_path = join(abspath(dirname(__file__)), _id) + + # For filtering our result set + module_filter_re = re.compile( + r'^(?P' + fname_prefix + + r'(?!Base|ImageSize|Type)[A-Za-z0-9]+)$') diff --git a/libs/apprise/persistent_store.py b/libs/apprise/persistent_store.py new file mode 100644 index 000000000..6151beb5e --- /dev/null +++ b/libs/apprise/persistent_store.py @@ -0,0 +1,1676 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2024 Chris Caron +# All rights reserved. +# +# This code is licensed under the MIT License. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files(the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions : +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +import os +import re +import gzip +import zlib +import base64 +import glob +import tempfile +import json +import binascii +from . import exception +from itertools import chain +from datetime import datetime, timezone, timedelta +import time +import hashlib +from .common import PersistentStoreMode, PERSISTENT_STORE_MODES +from .utils.disk import path_decode +from .logger import logger + +# Used for writing/reading time stored in cache file +EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc) + +# isoformat is spelled out for compatibility with Python v3.6 +AWARE_DATE_ISO_FORMAT = '%Y-%m-%dT%H:%M:%S.%f%z' +NAIVE_DATE_ISO_FORMAT = '%Y-%m-%dT%H:%M:%S.%f' + + +def _ntf_tidy(ntf): + """ + Reusable NamedTemporaryFile cleanup + """ + if ntf: + # Cleanup + try: + ntf.close() + + except OSError: + # Already closed + pass + + try: + os.unlink(ntf.name) + logger.trace( + 'Persistent temporary file removed: %s', ntf.name) + + except (FileNotFoundError, AttributeError): + # AttributeError: something weird was passed in, no action required + # FileNotFound: no worries; we were removing it anyway + pass + + except (OSError, IOError) as e: + logger.error( + 'Persistent temporary file removal failed: %s', + ntf.name) + logger.debug( + 'Persistent Storage Exception: %s', str(e)) + + +class CacheObject: + + hash_engine = hashlib.sha256 + hash_length = 6 + + def __init__(self, value=None, expires=False, persistent=True): + """ + Tracks our objects and associates a time limit with them + """ + + self.__value = value + self.__class_name = value.__class__.__name__ + self.__expires = None + + if expires: + self.set_expiry(expires) + + # Whether or not we persist this object to disk or not + self.__persistent = True if persistent else False + + def set(self, value, expires=None, persistent=None): + """ + Sets fields on demand, if set to none, then they are left as is + + The intent of set is that it allows you to set a new a value + and optionally alter meta information against it. + + If expires or persistent isn't specified then their previous values + are used. + + """ + + self.__value = value + self.__class_name = value.__class__.__name__ + if expires is not None: + self.set_expiry(expires) + + if persistent is not None: + self.__persistent = True if persistent else False + + def set_expiry(self, expires=None): + """ + Sets a new expiry + """ + + if isinstance(expires, datetime): + self.__expires = expires.astimezone(timezone.utc) + + elif expires in (None, False): + # Accepted - no expiry + self.__expires = None + + elif expires is True: + # Force expiry to now + self.__expires = datetime.now(tz=timezone.utc) + + elif isinstance(expires, (float, int)): + self.__expires = \ + datetime.now(tz=timezone.utc) + timedelta(seconds=expires) + + else: # Unsupported + raise AttributeError( + f"An invalid expiry time ({expires} was specified") + + def hash(self): + """ + Our checksum to track the validity of our data + """ + try: + return self.hash_engine( + str(self).encode('utf-8'), usedforsecurity=False).hexdigest() + + except TypeError: + # Python <= v3.8 - usedforsecurity flag does not work + return self.hash_engine(str(self).encode('utf-8')).hexdigest() + + def json(self): + """ + Returns our preparable json object + """ + + return { + 'v': self.__value, + 'x': (self.__expires - EPOCH).total_seconds() + if self.__expires else None, + 'c': self.__class_name if not isinstance(self.__value, datetime) + else ( + 'aware_datetime' if self.__value.tzinfo else 'naive_datetime'), + '!': self.hash()[:self.hash_length], + } + + @staticmethod + def instantiate(content, persistent=True, verify=True): + """ + Loads back data read in and returns a CacheObject or None if it could + not be loaded. You can pass in the contents of CacheObject.json() and + you'll receive a copy assuming the hash checks okay + + """ + try: + value = content['v'] + expires = content['x'] + if expires is not None: + expires = datetime.fromtimestamp(expires, timezone.utc) + + # Acquire some useful integrity objects + class_name = content.get('c', '') + if not isinstance(class_name, str): + raise TypeError('Class name not expected string') + + hashsum = content.get('!', '') + if not isinstance(hashsum, str): + raise TypeError('SHA1SUM not expected string') + + except (TypeError, KeyError) as e: + logger.trace(f'CacheObject could not be parsed from {content}') + logger.trace('CacheObject exception: %s', str(e)) + return None + + if class_name in ('aware_datetime', 'naive_datetime', 'datetime'): + # If datetime is detected, it will fall under the naive category + iso_format = AWARE_DATE_ISO_FORMAT \ + if class_name[0] == 'a' else NAIVE_DATE_ISO_FORMAT + try: + # Python v3.6 Support + value = datetime.strptime(value, iso_format) + + except (TypeError, ValueError): + # TypeError is thrown if content is not string + # ValueError is thrown if the string is not a valid format + logger.trace( + f'CacheObject (dt) corrupted loading from {content}') + return None + + elif class_name == 'bytes': + try: + # Convert our object back to a bytes + value = base64.b64decode(value) + + except binascii.Error: + logger.trace( + f'CacheObject (bin) corrupted loading from {content}') + return None + + # Initialize our object + co = CacheObject(value, expires, persistent=persistent) + if verify and co.hash()[:co.hash_length] != hashsum: + # Our object was tampered with + logger.debug(f'Tampering detected with cache entry {co}') + del co + return None + + return co + + @property + def value(self): + """ + Returns our value + """ + return self.__value + + @property + def persistent(self): + """ + Returns our persistent value + """ + return self.__persistent + + @property + def expires(self): + """ + Returns the datetime the object will expire + """ + return self.__expires + + @property + def expires_sec(self): + """ + Returns the number of seconds from now the object will expire + """ + + return None if self.__expires is None else max( + 0.0, (self.__expires - datetime.now(tz=timezone.utc)) + .total_seconds()) + + def __bool__(self): + """ + Returns True it the object hasn't expired, and False if it has + """ + if self.__expires is None: + # No Expiry + return True + + # Calculate if we've expired or not + return self.__expires > datetime.now(tz=timezone.utc) + + def __eq__(self, other): + """ + Handles equality == flag + """ + if isinstance(other, CacheObject): + return str(self) == str(other) + + return self.__value == other + + def __str__(self): + """ + string output of our data + """ + persistent = '+' if self.persistent else '-' + return f'{self.__class_name}:{persistent}:{self.__value} expires: ' +\ + ('never' if self.__expires is None + else self.__expires.strftime(NAIVE_DATE_ISO_FORMAT)) + + +class CacheJSONEncoder(json.JSONEncoder): + """ + A JSON Encoder for handling each of our cache objects + """ + + def default(self, entry): + if isinstance(entry, datetime): + return entry.strftime( + AWARE_DATE_ISO_FORMAT if entry.tzinfo is not None + else NAIVE_DATE_ISO_FORMAT) + + elif isinstance(entry, CacheObject): + return entry.json() + + elif isinstance(entry, bytes): + return base64.b64encode(entry).decode('utf-8') + + return super().default(entry) + + +class PersistentStore: + """ + An object to make working with persistent storage easier + + read() and write() are used for direct file i/o + + set(), get() are used for caching + """ + + # The maximum file-size we will allow the persistent store to grow to + # 1 MB = 1048576 bytes + max_file_size = 1048576 + + # 30 days in seconds + default_file_expiry = 2678400 + + # File encoding to use + encoding = 'utf-8' + + # Default data set + base_key = 'default' + + # Directory to store cache + __cache_key = 'cache' + + # Our Temporary working directory + temp_dir = 'tmp' + + # The directory our persistent store content gets placed in + data_dir = 'var' + + # Our Persistent Store File Extension + __extension = '.psdata' + + # Identify our backup file extension + __backup_extension = '._psbak' + + # Used to verify the key specified is valid + # - must start with an alpha_numeric + # - following optional characters can include period, underscore and + # equal + __valid_key = re.compile(r'[a-z0-9][a-z0-9._-]*', re.I) + + # Reference only + __not_found_ref = (None, None) + + def __init__(self, path=None, namespace='default', mode=None): + """ + Provide the namespace to work within. namespaces can only contain + alpha-numeric characters with the exception of '-' (dash), '_' + (underscore), and '.' (period). The namespace must be be relative + to the current URL being controlled. + """ + # Initalize our mode so __del__() calls don't go bad on the + # error checking below + self.__mode = None + + # Populated only once and after size() is called + self.__exclude_list = None + + # Files to renew on calls to flush + self.__renew = set() + + if not isinstance(namespace, str) \ + or not self.__valid_key.match(namespace): + raise AttributeError( + f"Persistent Storage namespace ({namespace}) provided is" + " invalid") + + if isinstance(path, str): + # A storage path has been defined + if mode is None: + # Store Default if no mode was provided along side of it + mode = PERSISTENT_STORE_MODES[0] + + # Store our information + self.__base_path = os.path.join(path_decode(path), namespace) + self.__temp_path = os.path.join(self.__base_path, self.temp_dir) + self.__data_path = os.path.join(self.__base_path, self.data_dir) + + else: # If no storage path is provide we set our mode to MEMORY + mode = PersistentStoreMode.MEMORY + self.__base_path = None + self.__temp_path = None + self.__data_path = None + + if mode not in PERSISTENT_STORE_MODES: + raise AttributeError( + f"Persistent Storage mode ({mode}) provided is invalid") + + # Store our mode + self.__mode = mode + + # Tracks when we have content to flush + self.__dirty = False + + # A caching value to track persistent storage disk size + self.__cache_size = None + self.__cache_files = {} + + # Internal Cache + self._cache = None + + # Prepare our environment + self.__prepare() + + def read(self, key=None, compress=True, expires=False): + """ + Returns the content of the persistent store object + + if refresh is set to True, then the file's modify time is updated + preventing it from getting caught in prune calls. It's a means + of allowing it to persist and not get cleaned up in later prune + calls. + + Content is always returned as a byte object + """ + try: + with self.open(key, mode="rb", compress=compress) as fd: + results = fd.read(self.max_file_size) + if expires is False: + self.__renew.add(os.path.join( + self.__data_path, f"{key}{self.__extension}")) + + return results + + except (FileNotFoundError, exception.AppriseDiskIOError): + # FileNotFoundError: No problem + # exception.AppriseDiskIOError: + # - Logging of error already occurred inside self.open() + pass + + except (OSError, zlib.error, EOFError, UnicodeDecodeError, + IOError) as e: + # We can't access the file or it does not exist + logger.warning('Could not read with persistent key: %s', key) + logger.debug('Persistent Storage Exception: %s', str(e)) + + # return none + return None + + def write(self, data, key=None, compress=True, _recovery=False): + """ + Writes the content to the persistent store if it doesn't exceed our + filesize limit. + + Content is always written as a byte object + + _recovery is reserved for internal usage and should not be changed + """ + + if key is None: + key = self.base_key + + elif not isinstance(key, str) or not self.__valid_key.match(key): + raise AttributeError( + f"Persistent Storage key ({key} provided is invalid") + + if not isinstance(data, (bytes, str)): + # One last check, we will accept read() objets with the expectation + # it will return a binary dataset + if not (hasattr(data, 'read') and callable(getattr(data, 'read'))): + raise AttributeError( + "Invalid data type {} provided to Persistent Storage" + .format(type(data))) + + try: + # Read in our data + data = data.read() + if not isinstance(data, (bytes, str)): + raise AttributeError( + "Invalid data type {} provided to Persistent Storage" + .format(type(data))) + + except Exception as e: + logger.warning( + 'Could read() from potential iostream with persistent ' + 'key: %s', key) + logger.debug('Persistent Storage Exception: %s', str(e)) + raise exception.AppriseDiskIOError( + "Invalid data type {} provided to Persistent Storage" + .format(type(data))) + + if self.__mode == PersistentStoreMode.MEMORY: + # Nothing further can be done + return False + + if _recovery: + # Attempt to recover from a bad directory structure or setup + self.__prepare() + + # generate our filename based on the key provided + io_file = os.path.join(self.__data_path, f"{key}{self.__extension}") + + # Calculate the files current filesize + try: + prev_size = os.stat(io_file).st_size + + except FileNotFoundError: + # No worries, no size to accomodate + prev_size = 0 + + except (OSError, IOError) as e: + # Permission error of some kind or disk problem... + # There is nothing we can do at this point + logger.warning('Could not write with persistent key: %s', key) + logger.debug('Persistent Storage Exception: %s', str(e)) + return False + + # Create a temporary file to write our content into + # ntf = NamedTemporaryFile + ntf = None + new_file_size = 0 + try: + if isinstance(data, str): + data = data.encode(self.encoding) + + ntf = tempfile.NamedTemporaryFile( + mode="wb", dir=self.__temp_path, + delete=False) + + # Close our file + ntf.close() + + # Pointer to our open call + _open = open if not compress else gzip.open + + with _open(ntf.name, mode='wb') as fd: + # Write our content + fd.write(data) + + # Get our file size + new_file_size = os.stat(ntf.name).st_size + + # Log our progress + logger.trace( + 'Wrote %d bytes of data to persistent key: %s', + new_file_size, key) + + except FileNotFoundError: + # This happens if the directory path is gone preventing the file + # from being created... + if not _recovery: + return self.write( + data=data, key=key, compress=compress, _recovery=True) + + # We've already made our best effort to recover if we are here in + # our code base... we're going to have to exit + + # Tidy our Named Temporary File + _ntf_tidy(ntf) + + # Early Exit + return False + + except (OSError, UnicodeEncodeError, IOError, zlib.error) as e: + # We can't access the file or it does not exist + logger.warning('Could not write to persistent key: %s', key) + logger.debug('Persistent Storage Exception: %s', str(e)) + + # Tidy our Named Temporary File + _ntf_tidy(ntf) + + return False + + if self.max_file_size > 0 and ( + new_file_size + self.size() - prev_size) > self.max_file_size: + # The content to store is to large + logger.warning( + 'Persistent content exceeds allowable maximum file length ' + '({}KB); provide {}KB'.format( + int(self.max_file_size / 1024), + int(new_file_size / 1024))) + return False + + # Return our final move + if not self.__move(ntf.name, io_file): + # Attempt to restore things as they were + + # Tidy our Named Temporary File + _ntf_tidy(ntf) + return False + + # Resetour reference variables + self.__cache_size = None + self.__cache_files.clear() + + # Content installed + return True + + def __move(self, src, dst): + """ + Moves the new file in place and handles the old if it exists already + If the transaction fails in any way, the old file is swapped back. + + Function returns True if successful and False if not. + """ + + # A temporary backup of the file we want to move in place + dst_backup = dst[:-len(self.__backup_extension)] + \ + self.__backup_extension + + # + # Backup the old file (if it exists) allowing us to have a restore + # point in the event of a failure + # + try: + # make sure the file isn't already present; if it is; remove it + os.unlink(dst_backup) + logger.trace( + 'Removed previous persistent backup file: %s', dst_backup) + + except FileNotFoundError: + # no worries; we were removing it anyway + pass + + except (OSError, IOError) as e: + # Permission error of some kind or disk problem... + # There is nothing we can do at this point + logger.warning( + 'Could not previous persistent data backup: %s', dst_backup) + logger.debug('Persistent Storage Exception: %s', str(e)) + return False + + try: + # Back our file up so we have a fallback + os.rename(dst, dst_backup) + logger.trace( + 'Persistent storage backup file created: %s', dst_backup) + + except FileNotFoundError: + # Not a problem; this is a brand new file we're writing + # There is nothing to backup + pass + + except (OSError, IOError) as e: + # This isn't good... we couldn't put our new file in place + logger.warning( + 'Could not install persistent content %s -> %s', + dst, os.path.basename(dst_backup)) + logger.debug('Persistent Storage Exception: %s', str(e)) + return False + + # + # Now place the new file + # + try: + os.rename(src, dst) + logger.trace('Persistent file installed: %s', dst) + + except (OSError, IOError) as e: + # This isn't good... we couldn't put our new file in place + # Begin fall-back process before leaving the funtion + logger.warning( + 'Could not install persistent content %s -> %s', + src, os.path.basename(dst)) + logger.debug('Persistent Storage Exception: %s', str(e)) + try: + # Restore our old backup (if it exists) + os.rename(dst_backup, dst) + logger.trace( + 'Restoring original persistent content: %s', dst) + + except FileNotFoundError: + # Not a problem + pass + + except (OSError, IOError) as e: + # Permission error of some kind or disk problem... + # There is nothing we can do at this point + logger.warning( + 'Failed to restore original persistent file: %s', dst) + logger.debug('Persistent Storage Exception: %s', str(e)) + + return False + + return True + + def open(self, key=None, mode='r', buffering=-1, encoding=None, + errors=None, newline=None, closefd=True, opener=None, + compress=False, compresslevel=9): + """ + Returns an iterator to our our file within our namespace identified + by the key provided. + + If no key is provided, then the default is used + """ + + if key is None: + key = self.base_key + + elif not isinstance(key, str) or not self.__valid_key.match(key): + raise AttributeError( + f"Persistent Storage key ({key} provided is invalid") + + if self.__mode == PersistentStoreMode.MEMORY: + # Nothing further can be done + raise FileNotFoundError() + + io_file = os.path.join(self.__data_path, f"{key}{self.__extension}") + try: + return open( + io_file, mode=mode, buffering=buffering, encoding=encoding, + errors=errors, newline=newline, closefd=closefd, + opener=opener) \ + if not compress else gzip.open( + io_file, compresslevel=compresslevel, encoding=encoding, + errors=errors, newline=newline) + + except FileNotFoundError: + # pass along (but wrap with Apprise exception) + raise exception.AppriseFileNotFound( + f"No such file or directory: '{io_file}'") + + except (OSError, IOError, zlib.error) as e: + # We can't access the file or it does not exist + logger.warning('Could not read with persistent key: %s', key) + logger.debug('Persistent Storage Exception: %s', str(e)) + raise exception.AppriseDiskIOError(str(e)) + + def get(self, key, default=None, lazy=True): + """ + Fetches from cache + """ + + if self._cache is None and not self.__load_cache(): + return default + + if key in self._cache and \ + not self.__mode == PersistentStoreMode.MEMORY and \ + not self.__dirty: + + # ensure we renew our content + self.__renew.add(self.cache_file) + + return self._cache[key].value \ + if key in self._cache and self._cache[key] else default + + def set(self, key, value, expires=None, persistent=True, lazy=True): + """ + Cache reference + """ + + if self._cache is None and not self.__load_cache(): + return False + + cache = CacheObject(value, expires, persistent=persistent) + # Fetch our cache value + try: + if lazy and cache == self._cache[key]: + # We're done; nothing further to do + return True + + except KeyError: + pass + + # Store our new cache + self._cache[key] = CacheObject(value, expires, persistent=persistent) + + # Set our dirty flag + self.__dirty = persistent + + if self.__dirty and self.__mode == PersistentStoreMode.FLUSH: + # Flush changes to disk + return self.flush() + + return True + + def clear(self, *args): + """ + Remove one or more cache entry by it's key + + e.g: clear('key') + clear('key1', 'key2', key-12') + + Or clear everything: + clear() + """ + if self._cache is None and not self.__load_cache(): + return False + + if args: + for arg in args: + + try: + del self._cache[arg] + + # Set our dirty flag (if not set already) + self.__dirty = True + + except KeyError: + pass + + elif self._cache: + # Request to remove everything and there is something to remove + + # Set our dirty flag (if not set already) + self.__dirty = True + + # Reset our object + self._cache.clear() + + if self.__dirty and self.__mode == PersistentStoreMode.FLUSH: + # Flush changes to disk + return self.flush() + + def prune(self): + """ + Eliminates expired cache entries + """ + if self._cache is None and not self.__load_cache(): + return False + + change = False + for key in list(self._cache.keys()): + if key not in self: + # It's identified as being expired + if not change and self._cache[key].persistent: + # track change only if content was persistent + change = True + + # Set our dirty flag + self.__dirty = True + + del self._cache[key] + + if self.__dirty and self.__mode == PersistentStoreMode.FLUSH: + # Flush changes to disk + return self.flush() + + return change + + def __load_cache(self, _recovery=False): + """ + Loads our cache + + _recovery is reserved for internal usage and should not be changed + """ + + # Prepare our dirty flag + self.__dirty = False + + if self.__mode == PersistentStoreMode.MEMORY: + # Nothing further to do + self._cache = {} + return True + + # Prepare our cache file + cache_file = self.cache_file + try: + with gzip.open(cache_file, 'rb') as f: + # Read our ontent from disk + self._cache = {} + for k, v in json.loads(f.read().decode(self.encoding)).items(): + co = CacheObject.instantiate(v) + if co: + # Verify our object before assigning it + self._cache[k] = co + + elif not self.__dirty: + # Track changes from our loadset + self.__dirty = True + + except (UnicodeDecodeError, json.decoder.JSONDecodeError, zlib.error, + TypeError, AttributeError, EOFError): + + # Let users known there was a problem + logger.warning( + 'Corrupted access persistent cache content: %s', + cache_file) + + if not _recovery: + try: + os.unlink(cache_file) + logger.trace( + 'Removed previous persistent cache content: %s', + cache_file) + + except FileNotFoundError: + # no worries; we were removing it anyway + pass + + except (OSError, IOError) as e: + # Permission error of some kind or disk problem... + # There is nothing we can do at this point + logger.warning( + 'Could not remove persistent cache content: %s', + cache_file) + logger.debug('Persistent Storage Exception: %s', str(e)) + return False + return self.__load_cache(_recovery=True) + + return False + + except FileNotFoundError: + # No problem; no cache to load + self._cache = {} + + except (OSError, IOError) as e: + # Permission error of some kind or disk problem... + # There is nothing we can do at this point + logger.warning( + 'Could not load persistent cache for namespace %s', + os.path.basename(self.__base_path)) + logger.debug('Persistent Storage Exception: %s', str(e)) + return False + + # Ensure our dirty flag is set to False + return True + + def __prepare(self, flush=True): + """ + Prepares a working environment + """ + if self.__mode != PersistentStoreMode.MEMORY: + # Ensure our path exists + try: + os.makedirs(self.__base_path, mode=0o770, exist_ok=True) + + except (OSError, IOError) as e: + # Permission error + logger.debug( + 'Could not create persistent store directory %s', + self.__base_path) + logger.debug('Persistent Storage Exception: %s', str(e)) + + # Mode changed back to MEMORY + self.__mode = PersistentStoreMode.MEMORY + + # Ensure our path exists + try: + os.makedirs(self.__temp_path, mode=0o770, exist_ok=True) + + except (OSError, IOError) as e: + # Permission error + logger.debug( + 'Could not create persistent store directory %s', + self.__temp_path) + logger.debug('Persistent Storage Exception: %s', str(e)) + + # Mode changed back to MEMORY + self.__mode = PersistentStoreMode.MEMORY + + try: + os.makedirs(self.__data_path, mode=0o770, exist_ok=True) + + except (OSError, IOError) as e: + # Permission error + logger.debug( + 'Could not create persistent store directory %s', + self.__data_path) + logger.debug('Persistent Storage Exception: %s', str(e)) + + # Mode changed back to MEMORY + self.__mode = PersistentStoreMode.MEMORY + + if self.__mode is PersistentStoreMode.MEMORY: + logger.warning( + 'The persistent storage could not be fully initialized; ' + 'operating in MEMORY mode') + + else: + if self._cache: + # Recovery taking place + self.__dirty = True + logger.warning( + 'The persistent storage environment was disrupted') + + if self.__mode is PersistentStoreMode.FLUSH and flush: + # Flush changes to disk + return self.flush(_recovery=True) + + def flush(self, force=False, _recovery=False): + """ + Save's our cache to disk + """ + + if self._cache is None or self.__mode == PersistentStoreMode.MEMORY: + # nothing to do + return True + + while self.__renew: + # update our files + path = self.__renew.pop() + ftime = time.time() + + try: + # (access_time, modify_time) + os.utime(path, (ftime, ftime)) + logger.trace('file timestamp updated: %s', path) + + except FileNotFoundError: + # No worries... move along + pass + + except (OSError, IOError) as e: + # We can't access the file or it does not exist + logger.debug('Could not update file timestamp: %s', path) + logger.debug('Persistent Storage Exception: %s', str(e)) + + if not force and self.__dirty is False: + # Nothing further to do + logger.trace('Persistent cache is consistent with memory map') + return True + + if _recovery: + # Attempt to recover from a bad directory structure or setup + self.__prepare(flush=False) + + # Unset our size lazy setting + self.__cache_size = None + self.__cache_files.clear() + + # Prepare our cache file + cache_file = self.cache_file + if not self._cache: + # + # We're deleting the cache file s there are no entries left in it + # + backup_file = cache_file[:-len(self.__backup_extension)] + \ + self.__backup_extension + + try: + os.unlink(backup_file) + logger.trace( + 'Removed previous persistent cache backup: %s', + backup_file) + + except FileNotFoundError: + # no worries; we were removing it anyway + pass + + except (OSError, IOError) as e: + # Permission error of some kind or disk problem... + # There is nothing we can do at this point + logger.warning( + 'Could not remove persistent cache backup: %s', + backup_file) + logger.debug('Persistent Storage Exception: %s', str(e)) + return False + + try: + os.rename(cache_file, backup_file) + logger.trace( + 'Persistent cache backup file created: %s', + backup_file) + + except FileNotFoundError: + # Not a problem; do not create a log entry + pass + + except (OSError, IOError) as e: + # This isn't good... we couldn't put our new file in place + logger.warning( + 'Could not remove stale persistent cache file: %s', + cache_file) + logger.debug('Persistent Storage Exception: %s', str(e)) + return False + return True + + # + # If we get here, we need to update our file based cache + # + + # ntf = NamedTemporaryFile + ntf = None + + try: + ntf = tempfile.NamedTemporaryFile( + mode="w+", encoding=self.encoding, dir=self.__temp_path, + delete=False) + + ntf.close() + + except FileNotFoundError: + # This happens if the directory path is gone preventing the file + # from being created... + if not _recovery: + return self.flush(force=True, _recovery=True) + + # We've already made our best effort to recover if we are here in + # our code base... we're going to have to exit + + # Tidy our Named Temporary File + _ntf_tidy(ntf) + + # Early Exit + return False + + except OSError as e: + logger.error( + 'Persistent temporary directory inaccessible: %s', + self.__temp_path) + logger.debug('Persistent Storage Exception: %s', str(e)) + + # Tidy our Named Temporary File + _ntf_tidy(ntf) + + # Early Exit + return False + + try: + # write our content currently saved to disk to our temporary file + with gzip.open(ntf.name, 'wb') as f: + # Write our content to disk + f.write(json.dumps( + {k: v for k, v in self._cache.items() + if v and v.persistent}, + separators=(',', ':'), + cls=CacheJSONEncoder).encode(self.encoding)) + + except TypeError as e: + # JSON object contains content that can not be encoded to disk + logger.error( + 'Persistent temporary file can not be written to ' + 'due to bad input data: %s', ntf.name) + logger.debug('Persistent Storage Exception: %s', str(e)) + + # Tidy our Named Temporary File + _ntf_tidy(ntf) + + # Early Exit + return False + + except (OSError, EOFError, zlib.error) as e: + logger.error( + 'Persistent temporary file inaccessible: %s', + ntf.name) + logger.debug('Persistent Storage Exception: %s', str(e)) + + # Tidy our Named Temporary File + _ntf_tidy(ntf) + + # Early Exit + return False + + if not self.__move(ntf.name, cache_file): + # Attempt to restore things as they were + + # Tidy our Named Temporary File + _ntf_tidy(ntf) + return False + + # Ensure our dirty flag is set to False + self.__dirty = False + + return True + + def files(self, exclude=True, lazy=True): + """ + Returns the total files + """ + + if lazy and exclude in self.__cache_files: + # Take an early exit with our cached results + return self.__cache_files[exclude] + + elif self.__mode == PersistentStoreMode.MEMORY: + # Take an early exit + # exclude is our cache switch and can be either True or False. + # For the below, we just set both cases and set them up as an + # empty record + self.__cache_files.update({True: [], False: []}) + return [] + + if not lazy or self.__exclude_list is None: + # A list of criteria that should be excluded from the size count + self.__exclude_list = ( + # Exclude backup cache file from count + re.compile(re.escape(os.path.join( + self.__base_path, + f'{self.__cache_key}{self.__backup_extension}'))), + + # Exclude temporary files + re.compile(re.escape(self.__temp_path) + r'[/\\].+'), + + # Exclude custom backup persistent files + re.compile( + re.escape(self.__data_path) + r'[/\\].+' + re.escape( + self.__backup_extension)), + ) + + try: + if exclude: + self.__cache_files[exclude] = \ + [path for path in filter(os.path.isfile, glob.glob( + os.path.join(self.__base_path, '**', '*'), + recursive=True)) + if next((False for p in self.__exclude_list + if p.match(path)), True)] + + else: # No exclusion list applied + self.__cache_files[exclude] = \ + [path for path in filter(os.path.isfile, glob.glob( + os.path.join(self.__base_path, '**', '*'), + recursive=True))] + + except (OSError, IOError): + # We can't access the directory or it does not exist + self.__cache_files[exclude] = [] + + return self.__cache_files[exclude] + + @staticmethod + def disk_scan(path, namespace=None, closest=True): + """ + Scansk a path provided and returns namespaces detected + """ + + logger.trace('Persistent path can of: %s', path) + + def is_namespace(x): + """ + Validate what was detected is a valid namespace + """ + return os.path.isdir(os.path.join(path, x)) \ + and PersistentStore.__valid_key.match(x) + + # Handle our namespace searching + if namespace: + if isinstance(namespace, str): + namespace = [namespace] + + elif not isinstance(namespace, (tuple, set, list)): + raise AttributeError( + "namespace must be None, a string, or a tuple/set/list " + "of strings") + + try: + # Acquire all of the files in question + namespaces = \ + [ns for ns in filter(is_namespace, os.listdir(path)) + if not namespace or next( + (True for n in namespace if ns.startswith(n)), False)] \ + if closest else \ + [ns for ns in filter(is_namespace, os.listdir(path)) + if not namespace or ns in namespace] + + except FileNotFoundError: + # no worries; Nothing to do + logger.debug('Disk Prune path not found; nothing to clean.') + return [] + + except (OSError, IOError) as e: + # Permission error of some kind or disk problem... + # There is nothing we can do at this point + logger.error( + 'Disk Scan detetcted inaccessible path: %s', path) + logger.debug( + 'Persistent Storage Exception: %s', str(e)) + return [] + + return namespaces + + @staticmethod + def disk_prune(path, namespace=None, expires=None, action=False): + """ + Prune persistent disk storage entries that are old and/or unreferenced + + you must specify a path to perform the prune within + + if one or more namespaces are provided, then pruning focuses ONLY on + those entries (if matched). + + if action is not set to False, directories to be removed are returned + only + + """ + + # Prepare our File Expiry + expires = datetime.now() - timedelta(seconds=expires) \ + if isinstance(expires, (float, int)) and expires >= 0 \ + else PersistentStore.default_file_expiry + + # Get our namespaces + namespaces = PersistentStore.disk_scan(path, namespace) + + # Track matches + _map = {} + + for namespace in namespaces: + # Prepare our map + _map[namespace] = [] + + # Reference Directories + base_dir = os.path.join(path, namespace) + data_dir = os.path.join(base_dir, PersistentStore.data_dir) + temp_dir = os.path.join(base_dir, PersistentStore.temp_dir) + + # Careful to only focus on files created by this Persistent Store + # object + files = [ + os.path.join(base_dir, f'{PersistentStore.__cache_key}' + f'{PersistentStore.__extension}'), + os.path.join(base_dir, f'{PersistentStore.__cache_key}' + f'{PersistentStore.__backup_extension}'), + ] + + # Update our files (applying what was defined above too) + valid_data_re = re.compile( + r'.*(' + re.escape(PersistentStore.__extension) + + r'|' + re.escape(PersistentStore.__backup_extension) + r')$') + + files = [path for path in filter( + os.path.isfile, chain(glob.glob( + os.path.join(data_dir, '*'), recursive=False), files)) + if valid_data_re.match(path)] + + # Now all temporary files + files.extend([path for path in filter( + os.path.isfile, glob.glob( + os.path.join(temp_dir, '*'), recursive=False))]) + + # Track if we should do a directory sweep later on + dir_sweep = True + + # Scan our files + for file in files: + try: + mtime = datetime.fromtimestamp(os.path.getmtime(file)) + + except FileNotFoundError: + # no worries; we were removing it anyway + continue + + except (OSError, IOError) as e: + # Permission error of some kind or disk problem... + # There is nothing we can do at this point + logger.error( + 'Disk Prune (ns=%s, clean=%s) detetcted inaccessible ' + 'file: %s', namespace, 'yes' if action else 'no', file) + logger.debug( + 'Persistent Storage Exception: %s', str(e)) + + # No longer worth doing a directory sweep + dir_sweep = False + continue + + if expires < mtime: + continue + + # + # Handle Removing + # + record = { + 'path': file, + 'removed': False, + } + + if action: + try: + os.unlink(file) + # Update our record + record['removed'] = True + logger.info( + 'Disk Prune (ns=%s, clean=%s) removed persistent ' + 'file: %s', namespace, + 'yes' if action else 'no', file) + + except FileNotFoundError: + # no longer worth doing a directory sweep + dir_sweep = False + + # otherwise, no worries; we were removing the file + # anyway + + except (OSError, IOError) as e: + # Permission error of some kind or disk problem... + # There is nothing we can do at this point + logger.error( + 'Disk Prune (ns=%s, clean=%s) failed to remove ' + 'persistent file: %s', namespace, + 'yes' if action else 'no', file) + + logger.debug( + 'Persistent Storage Exception: %s', str(e)) + + # No longer worth doing a directory sweep + dir_sweep = False + + # Store our record + _map[namespace].append(record) + + # Memory tidy + del files + + if dir_sweep: + # Gracefully cleanup our namespace directory. It's okay if we + # fail; This just means there were files in the directory. + for dirpath in (temp_dir, data_dir, base_dir): + if action: + try: + os.rmdir(dirpath) + logger.info( + 'Disk Prune (ns=%s, clean=%s) removed ' + 'persistent dir: %s', namespace, + 'yes' if action else 'no', dirpath) + except OSError: + # do nothing; + pass + return _map + + def size(self, exclude=True, lazy=True): + """ + Returns the total size of the persistent storage in bytes + """ + + if lazy and self.__cache_size is not None: + # Take an early exit + return self.__cache_size + + elif self.__mode == PersistentStoreMode.MEMORY: + # Take an early exit + self.__cache_size = 0 + return self.__cache_size + + # Get a list of files (file paths) in the given directory + try: + self.__cache_size = sum( + [os.stat(path).st_size for path in + self.files(exclude=exclude, lazy=lazy)]) + + except (OSError, IOError): + # We can't access the directory or it does not exist + self.__cache_size = 0 + + return self.__cache_size + + def __del__(self): + """ + Deconstruction of our object + """ + + if self.__mode == PersistentStoreMode.AUTO: + # Flush changes to disk + self.flush() + + def __delitem__(self, key): + """ + Remove a cache entry by it's key + """ + if self._cache is None and not self.__load_cache(): + raise KeyError("Could not initialize cache") + + try: + if self._cache[key].persistent: + # Set our dirty flag in advance + self.__dirty = True + + # Store our new cache + del self._cache[key] + + except KeyError: + # Nothing to do + raise + + if self.__dirty and self.__mode == PersistentStoreMode.FLUSH: + # Flush changes to disk + self.flush() + + return + + def __contains__(self, key): + """ + Verify if our storage contains the key specified or not. + In additiont to this, if the content is expired, it is considered + to be not contained in the storage. + """ + if self._cache is None and not self.__load_cache(): + return False + + return key in self._cache and self._cache[key] + + def __setitem__(self, key, value): + """ + Sets a cache value without disrupting existing settings in place + """ + + if self._cache is None and not self.__load_cache(): + raise KeyError("Could not initialize cache") + + if key not in self._cache and not self.set(key, value): + raise KeyError("Could not set cache") + + else: + # Update our value + self._cache[key].set(value) + + if self._cache[key].persistent: + # Set our dirty flag in advance + self.__dirty = True + + if self.__dirty and self.__mode == PersistentStoreMode.FLUSH: + # Flush changes to disk + self.flush() + + return + + def __getitem__(self, key): + """ + Returns the indexed value + """ + + if self._cache is None and not self.__load_cache(): + raise KeyError("Could not initialize cache") + + result = self.get(key, default=self.__not_found_ref, lazy=False) + if result is self.__not_found_ref: + raise KeyError(f" {key} not found in cache") + + return result + + def keys(self): + """ + Returns our keys + """ + if self._cache is None and not self.__load_cache(): + # There are no keys to return + return {}.keys() + + return self._cache.keys() + + def delete(self, *args, all=None, temp=None, cache=None, validate=True): + """ + Manages our file space and tidys it up + + delete('key', 'key2') + delete(all=True) + delete(temp=True, cache=True) + """ + + # Our failure flag + has_error = False + + valid_key_re = re.compile( + r'^(?P.+)(' + + re.escape(self.__backup_extension) + + r'|' + re.escape(self.__extension) + r')$', re.I) + + # Default asignments + if all is None: + all = True if not (len(args) or temp or cache) else False + if temp is None: + temp = True if all else False + if cache is None: + cache = True if all else False + + if cache and self._cache: + # Reset our object + self._cache.clear() + # Reset dirt flag + self.__dirty = False + + for path in self.files(exclude=False): + + # Some information we use to validate the actions of our clean() + # call. This is so we don't remove anything we shouldn't + base = os.path.dirname(path) + fname = os.path.basename(path) + + # Clean printable path details + ppath = os.path.join(os.path.dirname(base), fname) + + if base == self.__base_path and cache: + # We're handling a cache file (hopefully) + result = valid_key_re.match(fname) + key = None if not result else ( + result['key'] if self.__valid_key.match(result['key']) + else None) + + if validate and key != self.__cache_key: + # We're not dealing with a cache key + logger.debug( + 'Persistent File cleanup ignoring file: %s', path) + continue + + # + # We should proceed with removing the file if we get here + # + + elif base == self.__data_path and (args or all): + # We're handling a file found in our custom data path + result = valid_key_re.match(fname) + key = None if not result else ( + result['key'] if self.__valid_key.match(result['key']) + else None) + + if validate and key is None: + # we're set to validate and a non-valid file was found + logger.debug( + 'Persistent File cleanup ignoring file: %s', path) + continue + + elif not all and (key is None or key not in args): + # no match found + logger.debug( + 'Persistent File cleanup ignoring file: %s', path) + continue + + # + # We should proceed with removing the file if we get here + # + + elif base == self.__temp_path and temp: + # + # This directory is a temporary path and nothing in here needs + # to be further verified. Proceed with the removing of the file + # + pass + + else: + # No match; move on + logger.debug('Persistent File cleanup ignoring file: %s', path) + continue + + try: + os.unlink(path) + logger.info('Removed persistent file: %s', ppath) + + except FileNotFoundError: + # no worries; we were removing it anyway + pass + + except (OSError, IOError) as e: + # Permission error of some kind or disk problem... + # There is nothing we can do at this point + has_error = True + logger.error( + 'Failed to remove persistent file: %s', ppath) + logger.debug('Persistent Storage Exception: %s', str(e)) + + # Reset our reference variables + self.__cache_size = None + self.__cache_files.clear() + + return not has_error + + @property + def cache_file(self): + """ + Returns the full path to the namespace directory + """ + return os.path.join( + self.__base_path, + f'{self.__cache_key}{self.__extension}', + ) + + @property + def path(self): + """ + Returns the full path to the namespace directory + """ + return self.__base_path + + @property + def mode(self): + """ + Returns the full path to the namespace directory + """ + return self.__mode diff --git a/libs/apprise/plugins/NotifyBoxcar.py b/libs/apprise/plugins/NotifyBoxcar.py deleted file mode 100644 index 808920ed5..000000000 --- a/libs/apprise/plugins/NotifyBoxcar.py +++ /dev/null @@ -1,395 +0,0 @@ -# -*- coding: utf-8 -*- -# BSD 2-Clause License -# -# Apprise - Push Notification Library. -# Copyright (c) 2024, Chris Caron -# -# 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. - -import re -import requests -import hmac -from json import dumps -from time import time -from hashlib import sha1 -from itertools import chain -from urllib.parse import urlparse - -from .NotifyBase import NotifyBase -from ..URLBase import PrivacyMode -from ..utils import parse_bool -from ..utils import parse_list -from ..utils import validate_regex -from ..common import NotifyType -from ..common import NotifyImageSize -from ..AppriseLocale import gettext_lazy as _ - -# Default to sending to all devices if nothing is specified -DEFAULT_TAG = '@all' - -# The tags value is an structure containing an array of strings defining the -# list of tagged devices that the notification need to be send to, and a -# boolean operator (‘and’ / ‘or’) that defines the criteria to match devices -# against those tags. -IS_TAG = re.compile(r'^[@]?(?P[A-Z0-9]{1,63})$', re.I) - -# Device tokens are only referenced when developing. -# It's not likely you'll send a message directly to a device, but if you do; -# this plugin supports it. -IS_DEVICETOKEN = re.compile(r'^[A-Z0-9]{64}$', re.I) - -# Used to break apart list of potential tags by their delimiter into a useable -# list. -TAGS_LIST_DELIM = re.compile(r'[ \t\r\n,\\/]+') - - -class NotifyBoxcar(NotifyBase): - """ - A wrapper for Boxcar Notifications - """ - - # The default descriptive name associated with the Notification - service_name = 'Boxcar' - - # The services URL - service_url = 'https://boxcar.io/' - - # All boxcar notifications are secure - secure_protocol = 'boxcar' - - # A URL that takes you to the setup/help of the specific protocol - setup_url = 'https://github.com/caronc/apprise/wiki/Notify_boxcar' - - # Boxcar URL - notify_url = 'https://boxcar-api.io/api/push/' - - # Allows the user to specify the NotifyImageSize object - image_size = NotifyImageSize.XY_72 - - # The maximum allowable characters allowed in the body per message - body_maxlen = 10000 - - # Define object templates - templates = ( - '{schema}://{access_key}/{secret_key}/', - '{schema}://{access_key}/{secret_key}/{targets}', - ) - - # Define our template tokens - template_tokens = dict(NotifyBase.template_tokens, **{ - 'access_key': { - 'name': _('Access Key'), - 'type': 'string', - 'private': True, - 'required': True, - 'regex': (r'^[A-Z0-9_-]{64}$', 'i'), - 'map_to': 'access', - }, - 'secret_key': { - 'name': _('Secret Key'), - 'type': 'string', - 'private': True, - 'required': True, - 'regex': (r'^[A-Z0-9_-]{64}$', 'i'), - 'map_to': 'secret', - }, - 'target_tag': { - 'name': _('Target Tag ID'), - 'type': 'string', - 'prefix': '@', - 'regex': (r'^[A-Z0-9]{1,63}$', 'i'), - 'map_to': 'targets', - }, - 'target_device': { - 'name': _('Target Device ID'), - 'type': 'string', - 'regex': (r'^[A-Z0-9]{64}$', 'i'), - 'map_to': 'targets', - }, - 'targets': { - 'name': _('Targets'), - 'type': 'list:string', - }, - }) - - # Define our template arguments - template_args = dict(NotifyBase.template_args, **{ - 'image': { - 'name': _('Include Image'), - 'type': 'bool', - 'default': True, - 'map_to': 'include_image', - }, - 'to': { - 'alias_of': 'targets', - }, - 'access': { - 'alias_of': 'access_key', - }, - 'secret': { - 'alias_of': 'secret_key', - }, - }) - - def __init__(self, access, secret, targets=None, include_image=True, - **kwargs): - """ - Initialize Boxcar Object - """ - super().__init__(**kwargs) - - # Initialize tag list - self._tags = list() - - # Initialize device_token list - self.device_tokens = list() - - # Access Key (associated with project) - self.access = validate_regex( - access, *self.template_tokens['access_key']['regex']) - if not self.access: - msg = 'An invalid Boxcar Access Key ' \ - '({}) was specified.'.format(access) - self.logger.warning(msg) - raise TypeError(msg) - - # Secret Key (associated with project) - self.secret = validate_regex( - secret, *self.template_tokens['secret_key']['regex']) - if not self.secret: - msg = 'An invalid Boxcar Secret Key ' \ - '({}) was specified.'.format(secret) - self.logger.warning(msg) - raise TypeError(msg) - - if not targets: - self._tags.append(DEFAULT_TAG) - targets = [] - - # Validate targets and drop bad ones: - for target in parse_list(targets): - result = IS_TAG.match(target) - if result: - # store valid tag/alias - self._tags.append(result.group('name')) - continue - - result = IS_DEVICETOKEN.match(target) - if result: - # store valid device - self.device_tokens.append(target) - continue - - self.logger.warning( - 'Dropped invalid tag/alias/device_token ' - '({}) specified.'.format(target), - ) - - # Track whether or not we want to send an image with our notification - # or not. - self.include_image = include_image - - return - - def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): - """ - Perform Boxcar Notification - """ - headers = { - 'User-Agent': self.app_id, - 'Content-Type': 'application/json' - } - - # prepare Boxcar Object - payload = { - 'aps': { - 'badge': 'auto', - 'alert': '', - }, - 'expires': str(int(time() + 30)), - } - - if title: - payload['aps']['@title'] = title - - payload['aps']['alert'] = body - - if self._tags: - payload['tags'] = {'or': self._tags} - - if self.device_tokens: - payload['device_tokens'] = self.device_tokens - - # Source picture should be <= 450 DP wide, ~2:1 aspect. - image_url = None if not self.include_image \ - else self.image_url(notify_type) - - if image_url: - # Set our image - payload['@img'] = image_url - - # Acquire our hostname - host = urlparse(self.notify_url).hostname - - # Calculate signature. - str_to_sign = "%s\n%s\n%s\n%s" % ( - "POST", host, "/api/push", dumps(payload)) - - h = hmac.new( - bytearray(self.secret, 'utf-8'), - bytearray(str_to_sign, 'utf-8'), - sha1, - ) - - params = NotifyBoxcar.urlencode({ - "publishkey": self.access, - "signature": h.hexdigest(), - }) - - notify_url = '%s?%s' % (self.notify_url, params) - self.logger.debug('Boxcar POST URL: %s (cert_verify=%r)' % ( - notify_url, self.verify_certificate, - )) - self.logger.debug('Boxcar Payload: %s' % str(payload)) - - # Always call throttle before any remote server i/o is made - self.throttle() - - try: - r = requests.post( - notify_url, - data=dumps(payload), - headers=headers, - verify=self.verify_certificate, - timeout=self.request_timeout, - ) - - # Boxcar returns 201 (Created) when successful - if r.status_code != requests.codes.created: - # We had a problem - status_str = \ - NotifyBoxcar.http_response_code_lookup(r.status_code) - - self.logger.warning( - 'Failed to send Boxcar notification: ' - '{}{}error={}.'.format( - status_str, - ', ' if status_str else '', - r.status_code)) - - self.logger.debug('Response Details:\r\n{}'.format(r.content)) - - # Return; we're done - return False - - else: - self.logger.info('Sent Boxcar notification.') - - except requests.RequestException as e: - self.logger.warning( - 'A Connection error occurred sending Boxcar ' - 'notification to %s.' % (host)) - - self.logger.debug('Socket Exception: %s' % str(e)) - - # Return; we're done - return False - - return True - - def url(self, privacy=False, *args, **kwargs): - """ - Returns the URL built dynamically based on specified arguments. - """ - - # Define any URL parameters - params = { - 'image': 'yes' if self.include_image else 'no', - } - - # Extend our parameters - params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) - - return '{schema}://{access}/{secret}/{targets}?{params}'.format( - schema=self.secure_protocol, - access=self.pprint(self.access, privacy, safe=''), - secret=self.pprint( - self.secret, privacy, mode=PrivacyMode.Secret, safe=''), - targets='/'.join([ - NotifyBoxcar.quote(x, safe='') for x in chain( - self._tags, self.device_tokens) if x != DEFAULT_TAG]), - params=NotifyBoxcar.urlencode(params), - ) - - def __len__(self): - """ - Returns the number of targets associated with this notification - """ - targets = len(self._tags) + len(self.device_tokens) - # DEFAULT_TAG is set if no tokens/tags are otherwise set - return targets if targets > 0 else 1 - - @staticmethod - def parse_url(url): - """ - Parses the URL and returns it broken apart into a dictionary. - - """ - results = NotifyBase.parse_url(url, verify_host=False) - if not results: - # We're done early - return None - - # The first token is stored in the hostname - results['access'] = NotifyBoxcar.unquote(results['host']) - - # Get our entries; split_path() looks after unquoting content for us - # by default - entries = NotifyBoxcar.split_path(results['fullpath']) - - # Now fetch the remaining tokens - results['secret'] = entries.pop(0) if entries else None - - # Our recipients make up the remaining entries of our array - results['targets'] = entries - - # The 'to' makes it easier to use yaml configuration - if 'to' in results['qsd'] and len(results['qsd']['to']): - results['targets'] += \ - NotifyBoxcar.parse_list(results['qsd'].get('to')) - - # Access - if 'access' in results['qsd'] and results['qsd']['access']: - results['access'] = NotifyBoxcar.unquote( - results['qsd']['access'].strip()) - - # Secret - if 'secret' in results['qsd'] and results['qsd']['secret']: - results['secret'] = NotifyBoxcar.unquote( - results['qsd']['secret'].strip()) - - # Include images with our message - results['include_image'] = \ - parse_bool(results['qsd'].get('image', True)) - - return results diff --git a/libs/apprise/plugins/__init__.py b/libs/apprise/plugins/__init__.py index 72cb08fbf..62ddeaa5f 100644 --- a/libs/apprise/plugins/__init__.py +++ b/libs/apprise/plugins/__init__.py @@ -30,19 +30,18 @@ import copy # Used for testing -from .NotifyBase import NotifyBase +from .base import NotifyBase from ..common import NotifyImageSize from ..common import NOTIFY_IMAGE_SIZES from ..common import NotifyType from ..common import NOTIFY_TYPES -from ..utils import parse_list -from ..utils import cwe312_url -from ..utils import GET_SCHEMA_RE +from ..utils.cwe312 import cwe312_url +from ..utils.parse import parse_list, GET_SCHEMA_RE from ..logger import logger -from ..AppriseLocale import gettext_lazy as _ -from ..AppriseLocale import LazyTranslation -from ..NotificationManager import NotificationManager +from ..locale import gettext_lazy as _ +from ..locale import LazyTranslation +from ..manager_plugins import NotificationManager # Grant access to our Notification Manager Singleton diff --git a/libs/apprise/plugins/africas_talking.py b/libs/apprise/plugins/africas_talking.py new file mode 100644 index 000000000..98b2a208e --- /dev/null +++ b/libs/apprise/plugins/africas_talking.py @@ -0,0 +1,468 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2024, Chris Caron +# +# 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. + +# To use this plugin, you must have a Africas Talking Account setup; See here: +# https://account.africastalking.com/ +# From here... acquire your APIKey +# +# API Details: https://developers.africastalking.com/docs/sms/sending/bulk +import requests + +from .base import NotifyBase +from ..common import NotifyType +from ..utils.parse import ( + is_phone_no, parse_bool, parse_phone_no, validate_regex) +from ..locale import gettext_lazy as _ + + +class AfricasTalkingSMSMode: + """ + Africas Talking SMS Mode + """ + # BulkSMS Mode + BULKSMS = 'bulksms' + + # Premium Mode + PREMIUM = 'premium' + + # Sandbox Mode + SANDBOX = 'sandbox' + + +# Define the types in a list for validation purposes +AFRICAS_TALKING_SMS_MODES = ( + AfricasTalkingSMSMode.BULKSMS, + AfricasTalkingSMSMode.PREMIUM, + AfricasTalkingSMSMode.SANDBOX, +) + + +# Extend HTTP Error Messages +AFRICAS_TALKING_HTTP_ERROR_MAP = { + 100: 'Processed', + 101: 'Sent', + 102: 'Queued', + 401: 'Risk Hold', + 402: 'Invalid Sender ID', + 403: 'Invalid Phone Number', + 404: 'Unsupported Number Type', + 405: 'Insufficient Balance', + 406: 'User In Blacklist', + 407: 'Could Not Route', + 409: 'Do Not Disturb Rejection', + 500: 'Internal Server Error', + 501: 'Gateway Error', + 502: 'Rejected By Gateway', +} + + +class NotifyAfricasTalking(NotifyBase): + """ + A wrapper for Africas Talking Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'Africas Talking' + + # The services URL + service_url = 'https://africastalking.com/' + + # The default secure protocol + secure_protocol = 'atalk' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_africas_talking' + + # Africas Talking API Request URLs + notify_url = { + AfricasTalkingSMSMode.BULKSMS: + 'https://api.africastalking.com/version1/messaging', + AfricasTalkingSMSMode.PREMIUM: + 'https://content.africastalking.com/version1/messaging', + AfricasTalkingSMSMode.SANDBOX: + 'https://api.sandbox.africastalking.com/version1/messaging', + } + + # The maximum allowable characters allowed in the title per message + title_maxlen = 0 + + # The maximum allowable characters allowed in the body per message + body_maxlen = 160 + + # The maximum amount of phone numbers that can reside within a single + # batch transfer + default_batch_size = 50 + + # Define object templates + templates = ( + '{schema}://{appuser}@{apikey}/{targets}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'appuser': { + 'name': _('App User Name'), + 'type': 'string', + 'regex': (r'^[A-Z0-9_-]+$', 'i'), + 'required': True, + }, + 'apikey': { + 'name': _('API Key'), + 'type': 'string', + 'required': True, + 'private': True, + 'regex': (r'^[A-Z0-9_-]+$', 'i'), + }, + 'target_phone': { + 'name': _('Target Phone'), + 'type': 'string', + 'map_to': 'targets', + }, + 'targets': { + 'name': _('Targets'), + 'type': 'list:string', + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'to': { + 'alias_of': 'targets', + }, + 'apikey': { + 'alias_of': 'apikey', + }, + 'from': { + # Your registered short code or alphanumeric + 'name': _('From'), + 'type': 'string', + 'default': 'AFRICASTKNG', + 'map_to': 'sender', + }, + 'batch': { + 'name': _('Batch Mode'), + 'type': 'bool', + 'default': False, + }, + 'mode': { + 'name': _('SMS Mode'), + 'type': 'choice:string', + 'values': AFRICAS_TALKING_SMS_MODES, + 'default': AFRICAS_TALKING_SMS_MODES[0], + }, + }) + + def __init__(self, appuser, apikey, targets=None, sender=None, batch=None, + mode=None, **kwargs): + """ + Initialize Africas Talking Object + """ + super().__init__(**kwargs) + + self.appuser = validate_regex( + appuser, *self.template_tokens['appuser']['regex']) + if not self.appuser: + msg = 'The Africas Talking appuser specified ({}) is invalid.'\ + .format(appuser) + self.logger.warning(msg) + raise TypeError(msg) + + self.apikey = validate_regex( + apikey, *self.template_tokens['apikey']['regex']) + if not self.apikey: + msg = 'The Africas Talking apikey specified ({}) is invalid.'\ + .format(apikey) + self.logger.warning(msg) + raise TypeError(msg) + + # Prepare Sender + self.sender = self.template_args['from']['default'] \ + if sender is None else sender + + # Prepare Batch Mode Flag + self.batch = self.template_args['batch']['default'] \ + if batch is None else batch + + self.mode = self.template_args['mode']['default'] \ + if not isinstance(mode, str) else mode.lower() + + if isinstance(mode, str) and mode: + self.mode = next( + (a for a in AFRICAS_TALKING_SMS_MODES if a.startswith( + mode.lower())), None) + + if self.mode not in AFRICAS_TALKING_SMS_MODES: + msg = 'The Africas Talking mode specified ({}) is invalid.'\ + .format(mode) + self.logger.warning(msg) + raise TypeError(msg) + else: + self.mode = self.template_args['mode']['default'] + + # Parse our targets + self.targets = list() + + for target in parse_phone_no(targets): + # Validate targets and drop bad ones: + result = is_phone_no(target) + if not result: + self.logger.warning( + 'Dropped invalid phone # ' + '({}) specified.'.format(target), + ) + continue + + # store valid phone number + # Carry forward '+' if defined, otherwise do not... + self.targets.append( + ('+' + result['full']) + if target.lstrip()[0] == '+' else result['full']) + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform Africas Talking Notification + """ + + if not self.targets: + # There is no one to email; we're done + self.logger.warning( + 'There are no Africas Talking recipients to notify') + return False + + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json', + 'apiKey': self.apikey, + } + + # error tracking (used for function return) + has_error = False + + # Send in batches if identified to do so + batch_size = 1 if not self.batch else self.default_batch_size + + # Create a copy of the target list + for index in range(0, len(self.targets), batch_size): + # Prepare our payload + payload = { + 'username': self.appuser, + 'to': ','.join(self.targets[index:index + batch_size]), + 'from': self.sender, + 'message': body, + } + + # Acquire our URL + notify_url = self.notify_url[self.mode] + + self.logger.debug( + 'Africas Talking POST URL: %s (cert_verify=%r)' % ( + notify_url, self.verify_certificate)) + self.logger.debug('Africas Talking Payload: %s' % str(payload)) + + # Printable target detail + p_target = self.targets[index] if batch_size == 1 \ + else '{} target(s)'.format( + len(self.targets[index:index + batch_size])) + + # Always call throttle before any remote server i/o is made + self.throttle() + try: + r = requests.post( + notify_url, + data=payload, + headers=headers, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + # Sample response + # { + # "SMSMessageData": { + # "Message": "Sent to 1/1 Total Cost: KES 0.8000", + # "Recipients": [{ + # "statusCode": 101, + # "number": "+254711XXXYYY", + # "status": "Success", + # "cost": "KES 0.8000", + # "messageId": "ATPid_SampleTxnId123" + # }] + # } + # } + + if r.status_code not in (100, 101, 102, requests.codes.ok): + # We had a problem + status_str = \ + NotifyAfricasTalking.http_response_code_lookup( + r.status_code, AFRICAS_TALKING_HTTP_ERROR_MAP) + + self.logger.warning( + 'Failed to send Africas Talking notification to {}: ' + '{}{}error={}.'.format( + p_target, + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + # Mark our failure + has_error = True + continue + + else: + self.logger.info( + 'Sent Africas Talking notification to {}.' + .format(p_target)) + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending Africas Talking ' + 'notification to {}.'.format(p_target)) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Mark our failure + has_error = True + continue + + return not has_error + + @property + def url_identifier(self): + """ + Returns all of the identifiers that make this URL unique from + another simliar one. Targets or end points should never be identified + here. + """ + return (self.secure_protocol, self.appuser, self.apikey) + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any URL parameters + params = { + 'batch': 'yes' if self.batch else 'no', + } + + if self.sender != self.template_args['from']['default']: + # Set our sender if it was set + params['from'] = self.sender + + if self.mode != self.template_args['mode']['default']: + # Set our mode + params['mode'] = self.mode + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + return '{schema}://{appuser}@{apikey}/{targets}?{params}'.format( + schema=self.secure_protocol, + appuser=NotifyAfricasTalking.quote(self.appuser, safe=''), + apikey=self.pprint(self.apikey, privacy, safe=''), + targets='/'.join( + [NotifyAfricasTalking.quote(x, safe='+') + for x in self.targets]), + params=NotifyAfricasTalking.urlencode(params), + ) + + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + # + # Factor batch into calculation + # + batch_size = 1 if not self.batch else self.default_batch_size + targets = len(self.targets) + if batch_size > 1: + targets = int(targets / batch_size) + \ + (1 if targets % batch_size else 0) + + return targets if targets > 0 else 1 + + @staticmethod + def parse_url(url): + """ + Parses the URL and returns enough arguments that can allow + us to re-instantiate this object. + + """ + results = NotifyBase.parse_url(url, verify_host=False) + if not results: + # We're done early as we couldn't load the results + return results + + # The Application User ID + results['appuser'] = NotifyAfricasTalking.unquote(results['user']) + + # Prepare our targets + results['targets'] = [] + + # Our Application APIKey + if 'apikey' in results['qsd'] and len(results['qsd']['apikey']): + # Store our apikey if specified as keyword + results['apikey'] = \ + NotifyAfricasTalking.unquote(results['qsd']['apikey']) + + # This means our host is actually a phone number (target) + results['targets'].append( + NotifyAfricasTalking.unquote(results['host'])) + + else: + # First item is our apikey + results['apikey'] = NotifyAfricasTalking.unquote(results['host']) + + # Store our remaining targets found on path + results['targets'].extend( + NotifyAfricasTalking.split_path(results['fullpath'])) + + # The 'from' makes it easier to use yaml configuration + if 'from' in results['qsd'] and len(results['qsd']['from']): + results['sender'] = \ + NotifyAfricasTalking.unquote(results['qsd']['from']) + + # Support the 'to' variable so that we can support targets this way too + # The 'to' makes it easier to use yaml configuration + if 'to' in results['qsd'] and len(results['qsd']['to']): + results['targets'] += \ + NotifyAfricasTalking.parse_phone_no(results['qsd']['to']) + + # Get our Mode + if 'mode' in results['qsd'] and len(results['qsd']['mode']): + results['mode'] = \ + NotifyAfricasTalking.unquote(results['qsd']['mode']) + + # Get Batch Mode Flag + results['batch'] = \ + parse_bool(results['qsd'].get( + 'batch', + NotifyAfricasTalking.template_args['batch']['default'])) + + return results diff --git a/libs/apprise/plugins/NotifyAppriseAPI.py b/libs/apprise/plugins/apprise_api.py similarity index 92% rename from libs/apprise/plugins/NotifyAppriseAPI.py rename to libs/apprise/plugins/apprise_api.py index 34c34a6d4..bd1177930 100644 --- a/libs/apprise/plugins/NotifyAppriseAPI.py +++ b/libs/apprise/plugins/apprise_api.py @@ -29,14 +29,13 @@ import re import requests from json import dumps -import base64 -from .NotifyBase import NotifyBase -from ..URLBase import PrivacyMode +from .. import exception +from .base import NotifyBase +from ..url import PrivacyMode from ..common import NotifyType -from ..utils import parse_list -from ..utils import validate_regex -from ..AppriseLocale import gettext_lazy as _ +from ..utils.parse import parse_list, validate_regex +from ..locale import gettext_lazy as _ class AppriseAPIMethod: @@ -123,7 +122,7 @@ class NotifyAppriseAPI(NotifyBase): 'type': 'string', 'required': True, 'private': True, - 'regex': (r'^[A-Z0-9_-]{1,32}$', 'i'), + 'regex': (r'^[A-Z0-9_-]{1,128}$', 'i'), }, }) @@ -261,39 +260,45 @@ def send(self, body, title='', notify_type=NotifyType.INFO, attach=None, if not attachment: # We could not access the attachment self.logger.error( - 'Could not access attachment {}.'.format( + 'Could not access Apprise API attachment {}.'.format( attachment.url(privacy=True))) return False try: + # Our Attachment filename + filename = attachment.name \ + if attachment.name else f'file{no:03}.dat' + if self.method == AppriseAPIMethod.JSON: - with open(attachment.path, 'rb') as f: - # Output must be in a DataURL format (that's what - # PushSafer calls it): - attachments.append({ - 'filename': attachment.name, - 'base64': base64.b64encode(f.read()) - .decode('utf-8'), - 'mimetype': attachment.mimetype, - }) + # Output must be in a DataURL format (that's what + # PushSafer calls it): + attachments.append({ + "filename": filename, + 'base64': attachment.base64(), + 'mimetype': attachment.mimetype, + }) else: # AppriseAPIMethod.FORM files.append(( 'file{:02d}'.format(no), ( - attachment.name, + filename, open(attachment.path, 'rb'), attachment.mimetype, ) )) - except (OSError, IOError) as e: - self.logger.warning( - 'An I/O error occurred while reading {}.'.format( - attachment.name if attachment else 'attachment')) - self.logger.debug('I/O Exception: %s' % str(e)) + except (TypeError, OSError, exception.AppriseException): + # We could not access the attachment + self.logger.error( + 'Could not access AppriseAPI attachment {}.'.format( + attachment.url(privacy=True))) return False + self.logger.debug( + 'Appending AppriseAPI attachment {}'.format( + attachment.url(privacy=True))) + # prepare Apprise API Object payload = { # Apprise API Payload diff --git a/libs/apprise/plugins/NotifyAprs.py b/libs/apprise/plugins/aprs.py similarity index 98% rename from libs/apprise/plugins/NotifyAprs.py rename to libs/apprise/plugins/aprs.py index 5d8c3c100..d3c9dc1a2 100644 --- a/libs/apprise/plugins/NotifyAprs.py +++ b/libs/apprise/plugins/aprs.py @@ -70,12 +70,11 @@ import socket import sys from itertools import chain -from .NotifyBase import NotifyBase -from ..AppriseLocale import gettext_lazy as _ -from ..URLBase import PrivacyMode +from .base import NotifyBase +from ..locale import gettext_lazy as _ +from ..url import PrivacyMode from ..common import NotifyType -from ..utils import is_call_sign -from ..utils import parse_call_sign +from ..utils.parse import is_call_sign, parse_call_sign from .. import __version__ import re @@ -729,6 +728,15 @@ def url(self, privacy=False, *args, **kwargs): params=NotifyAprs.urlencode(params), ) + @property + def url_identifier(self): + """ + Returns all of the identifiers that make this URL unique from + another simliar one. Targets or end points should never be identified + here. + """ + return (self.user, self.password, self.locale) + def __len__(self): """ Returns the number of targets associated with this notification diff --git a/libs/apprise/plugins/NotifyBark.py b/libs/apprise/plugins/bark.py similarity index 90% rename from libs/apprise/plugins/NotifyBark.py rename to libs/apprise/plugins/bark.py index 781a1515e..0e415a290 100644 --- a/libs/apprise/plugins/NotifyBark.py +++ b/libs/apprise/plugins/bark.py @@ -32,13 +32,12 @@ import requests import json -from .NotifyBase import NotifyBase -from ..URLBase import PrivacyMode +from .base import NotifyBase +from ..url import PrivacyMode from ..common import NotifyImageSize from ..common import NotifyType -from ..utils import parse_list -from ..utils import parse_bool -from ..AppriseLocale import gettext_lazy as _ +from ..utils.parse import parse_list, parse_bool +from ..locale import gettext_lazy as _ # Sounds generated off of: https://github.com/Finb/Bark/tree/master/Sounds @@ -89,11 +88,14 @@ class NotifyBarkLevel: PASSIVE = 'passive' + CRITICAL = 'critical' + BARK_LEVELS = ( NotifyBarkLevel.ACTIVE, NotifyBarkLevel.TIME_SENSITIVE, NotifyBarkLevel.PASSIVE, + NotifyBarkLevel.CRITICAL, ) @@ -178,6 +180,12 @@ class NotifyBark(NotifyBase): 'type': 'choice:string', 'values': BARK_LEVELS, }, + 'volume': { + 'name': _('Volume'), + 'type': 'int', + 'min': 0, + 'max': 10, + }, 'click': { 'name': _('Click'), 'type': 'string', @@ -205,7 +213,7 @@ class NotifyBark(NotifyBase): def __init__(self, targets=None, include_image=True, sound=None, category=None, group=None, level=None, click=None, - badge=None, **kwargs): + badge=None, volume=None, **kwargs): """ Initialize Notify Bark Object """ @@ -260,6 +268,19 @@ def __init__(self, targets=None, include_image=True, sound=None, self.logger.warning( 'The specified Bark sound ({}) was not found ', sound) + # Volume + self.volume = None + if volume is not None: + try: + self.volume = int(volume) if volume is not None else None + if self.volume is not None and not (0 <= self.volume <= 10): + raise ValueError() + + except (TypeError, ValueError): + self.logger.warning( + 'The specified Bark volume ({}) is not valid. ' + 'Must be between 0 and 10', volume) + # Level self.level = None if not level else next( (f for f in BARK_LEVELS if f[0] == level[0]), None) @@ -330,6 +351,9 @@ def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): if self.group: payload['group'] = self.group + if self.volume: + payload['volume'] = self.volume + auth = None if self.user: auth = (self.user, self.password) @@ -395,6 +419,18 @@ def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): return not has_error + @property + def url_identifier(self): + """ + Returns all of the identifiers that make this URL unique from + another simliar one. Targets or end points should never be identified + here. + """ + return ( + self.secure_protocol if self.secure else self.protocol, + self.user, self.password, self.host, self.port, + ) + def url(self, privacy=False, *args, **kwargs): """ Returns the URL built dynamically based on specified arguments. @@ -417,6 +453,9 @@ def url(self, privacy=False, *args, **kwargs): if self.level: params['level'] = self.level + if self.volume: + params['volume'] = str(self.volume) + if self.category: params['category'] = self.category @@ -490,6 +529,11 @@ def parse_url(url): results['badge'] = NotifyBark.unquote( results['qsd']['badge'].strip()) + # Volume + if 'volume' in results['qsd'] and results['qsd']['volume']: + results['volume'] = NotifyBark.unquote( + results['qsd']['volume'].strip()) + # Level if 'level' in results['qsd'] and results['qsd']['level']: results['level'] = NotifyBark.unquote( diff --git a/libs/apprise/plugins/NotifyBase.py b/libs/apprise/plugins/base.py similarity index 92% rename from libs/apprise/plugins/NotifyBase.py rename to libs/apprise/plugins/base.py index c29417c60..b7f774423 100644 --- a/libs/apprise/plugins/NotifyBase.py +++ b/libs/apprise/plugins/base.py @@ -30,16 +30,18 @@ import re from functools import partial -from ..URLBase import URLBase +from ..url import URLBase from ..common import NotifyType -from ..utils import parse_bool +from ..utils.parse import parse_bool from ..common import NOTIFY_TYPES from ..common import NotifyFormat from ..common import NOTIFY_FORMATS from ..common import OverflowMode from ..common import OVERFLOW_MODES -from ..AppriseLocale import gettext_lazy as _ -from ..AppriseAttachment import AppriseAttachment +from ..common import PersistentStoreMode +from ..locale import gettext_lazy as _ +from ..persistent_store import PersistentStore +from ..apprise_attachment import AppriseAttachment class NotifyBase(URLBase): @@ -130,12 +132,19 @@ class NotifyBase(URLBase): # of lines. Setting this to zero disables this feature. body_max_line_count = 0 + # Persistent storage default settings + persistent_storage = True + # Default Notify Format notify_format = NotifyFormat.TEXT # Default Overflow Mode overflow_mode = OverflowMode.UPSTREAM + # Our default is to no not use persistent storage beyond in-memory + # reference + storage_mode = PersistentStoreMode.MEMORY + # Default Emoji Interpretation interpret_emojis = False @@ -197,6 +206,16 @@ class NotifyBase(URLBase): # runtime. '_lookup_default': 'interpret_emojis', }, + 'store': { + 'name': _('Persistent Storage'), + # Use Persistent Storage + 'type': 'bool', + # Provide a default + 'default': persistent_storage, + # look up default using the following parent class value at + # runtime. + '_lookup_default': 'persistent_storage', + }, }) # @@ -268,6 +287,9 @@ def __init__(self, **kwargs): # are turned off (no user over-rides allowed) # + # Our Persistent Storage object is initialized on demand + self.__store = None + # Take a default self.interpret_emojis = self.asset.interpret_emojis if 'emojis' in kwargs: @@ -301,6 +323,14 @@ def __init__(self, **kwargs): # Provide override self.overflow_mode = overflow + # Prepare our Persistent Storage switch + self.persistent_storage = parse_bool( + kwargs.get('store', NotifyBase.persistent_storage)) + if not self.persistent_storage: + # Enforce the disabling of cache (ortherwise defaults are use) + self.url_identifier = False + self.__cached_url_identifier = None + def image_url(self, notify_type, logo=False, extension=None, image_size=None): """ @@ -364,6 +394,17 @@ def color(self, notify_type, color_type=None): color_type=color_type, ) + def ascii(self, notify_type): + """ + Returns the ascii characters associated with the notify_type + """ + if notify_type not in NOTIFY_TYPES: + return None + + return self.asset.ascii( + notify_type=notify_type, + ) + def notify(self, *args, **kwargs): """ Performs notification @@ -715,6 +756,10 @@ def url_parameters(self, *args, **kwargs): 'overflow': self.overflow_mode, } + # Persistent Storage Setting + if self.persistent_storage != NotifyBase.persistent_storage: + params['store'] = 'yes' if self.persistent_storage else 'no' + params.update(super().url_parameters(*args, **kwargs)) # return default parameters @@ -767,6 +812,10 @@ def parse_url(url, verify_host=True, plus_to_space=False): # Allow emoji's override if 'emojis' in results['qsd']: results['emojis'] = parse_bool(results['qsd'].get('emojis')) + # Store our persistent storage boolean + + if 'store' in results['qsd']: + results['store'] = results['qsd']['store'] return results @@ -787,3 +836,29 @@ def parse_native_url(url): should return the same set of results that parse_url() does. """ return None + + @property + def store(self): + """ + Returns a pointer to our persistent store for use. + + The best use cases are: + self.store.get('key') + self.store.set('key', 'value') + self.store.delete('key1', 'key2', ...) + + You can also access the keys this way: + self.store['key'] + + And clear them: + del self.store['key'] + + """ + if self.__store is None: + # Initialize our persistent store for use + self.__store = PersistentStore( + namespace=self.url_id(), + path=self.asset.storage_path, + mode=self.asset.storage_mode) + + return self.__store diff --git a/libs/apprise/plugins/NotifyBase.pyi b/libs/apprise/plugins/base.pyi similarity index 100% rename from libs/apprise/plugins/NotifyBase.pyi rename to libs/apprise/plugins/base.pyi diff --git a/libs/apprise/plugins/NotifyBulkSMS.py b/libs/apprise/plugins/bulksms.py similarity index 96% rename from libs/apprise/plugins/NotifyBulkSMS.py rename to libs/apprise/plugins/bulksms.py index 33664fb00..ce0daef49 100644 --- a/libs/apprise/plugins/NotifyBulkSMS.py +++ b/libs/apprise/plugins/bulksms.py @@ -36,13 +36,11 @@ import requests import json from itertools import chain -from .NotifyBase import NotifyBase -from ..URLBase import PrivacyMode +from .base import NotifyBase +from ..url import PrivacyMode from ..common import NotifyType -from ..utils import is_phone_no -from ..utils import parse_phone_no -from ..utils import parse_bool -from ..AppriseLocale import gettext_lazy as _ +from ..utils.parse import is_phone_no, parse_phone_no, parse_bool +from ..locale import gettext_lazy as _ IS_GROUP_RE = re.compile( @@ -269,7 +267,7 @@ def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): 'to': None, 'body': body, 'routingGroup': self.route, - 'encoding': BulkSMSEncoding.UNICODE \ + 'encoding': BulkSMSEncoding.UNICODE if self.unicode else BulkSMSEncoding.TEXT, # Options are NONE, ALL and ERRORS 'deliveryReports': "ERRORS" @@ -413,6 +411,19 @@ def url(self, privacy=False, *args, **kwargs): for x in self.groups])), params=NotifyBulkSMS.urlencode(params)) + @property + def url_identifier(self): + """ + Returns all of the identifiers that make this URL unique from + another simliar one. Targets or end points should never be identified + here. + """ + return ( + self.secure_protocol, + self.user if self.user else None, + self.password if self.password else None, + ) + def __len__(self): """ Returns the number of targets associated with this notification diff --git a/libs/apprise/plugins/NotifyBulkVS.py b/libs/apprise/plugins/bulkvs.py similarity index 96% rename from libs/apprise/plugins/NotifyBulkVS.py rename to libs/apprise/plugins/bulkvs.py index e912dff25..14a1369ed 100644 --- a/libs/apprise/plugins/NotifyBulkVS.py +++ b/libs/apprise/plugins/bulkvs.py @@ -35,13 +35,11 @@ # Messaging/post_messageSend import requests import json -from .NotifyBase import NotifyBase -from ..URLBase import PrivacyMode +from .base import NotifyBase +from ..url import PrivacyMode from ..common import NotifyType -from ..utils import is_phone_no -from ..utils import parse_phone_no -from ..utils import parse_bool -from ..AppriseLocale import gettext_lazy as _ +from ..utils.parse import is_phone_no, parse_phone_no, parse_bool +from ..locale import gettext_lazy as _ class NotifyBulkVS(NotifyBase): @@ -304,6 +302,15 @@ def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): return not has_error + @property + def url_identifier(self): + """ + Returns all of the identifiers that make this URL unique from + another simliar one. Targets or end points should never be identified + here. + """ + return (self.secure_protocol, self.source, self.user, self.password) + def url(self, privacy=False, *args, **kwargs): """ Returns the URL built dynamically based on specified arguments. diff --git a/libs/apprise/plugins/NotifyBurstSMS.py b/libs/apprise/plugins/burstsms.py similarity index 96% rename from libs/apprise/plugins/NotifyBurstSMS.py rename to libs/apprise/plugins/burstsms.py index 39606abba..898f98641 100644 --- a/libs/apprise/plugins/NotifyBurstSMS.py +++ b/libs/apprise/plugins/burstsms.py @@ -33,14 +33,12 @@ # import requests -from .NotifyBase import NotifyBase -from ..URLBase import PrivacyMode +from .base import NotifyBase +from ..url import PrivacyMode from ..common import NotifyType -from ..utils import is_phone_no -from ..utils import parse_phone_no -from ..utils import parse_bool -from ..utils import validate_regex -from ..AppriseLocale import gettext_lazy as _ +from ..utils.parse import ( + is_phone_no, parse_phone_no, parse_bool, validate_regex) +from ..locale import gettext_lazy as _ class BurstSMSCountryCode: @@ -378,6 +376,15 @@ def url(self, privacy=False, *args, **kwargs): [NotifyBurstSMS.quote(x, safe='') for x in self.targets]), params=NotifyBurstSMS.urlencode(params)) + @property + def url_identifier(self): + """ + Returns all of the identifiers that make this URL unique from + another simliar one. Targets or end points should never be identified + here. + """ + return (self.secure_protocol, self.apikey, self.secret, self.source) + def __len__(self): """ Returns the number of targets associated with this notification diff --git a/libs/apprise/plugins/NotifyChantify.py b/libs/apprise/plugins/chantify.py similarity index 94% rename from libs/apprise/plugins/NotifyChantify.py rename to libs/apprise/plugins/chantify.py index d912bd257..e9be66161 100644 --- a/libs/apprise/plugins/NotifyChantify.py +++ b/libs/apprise/plugins/chantify.py @@ -35,10 +35,10 @@ import requests -from .NotifyBase import NotifyBase +from .base import NotifyBase from ..common import NotifyType -from ..utils import validate_regex -from ..AppriseLocale import gettext_lazy as _ +from ..utils.parse import validate_regex +from ..locale import gettext_lazy as _ class NotifyChantify(NotifyBase): @@ -181,6 +181,15 @@ def url(self, privacy=False, *args, **kwargs): params=NotifyChantify.urlencode(params), ) + @property + def url_identifier(self): + """ + Returns all of the identifiers that make this URL unique from + another simliar one. Targets or end points should never be identified + here. + """ + return (self.secure_protocol, self.token) + @staticmethod def parse_url(url): """ diff --git a/libs/apprise/plugins/NotifyClickSend.py b/libs/apprise/plugins/clicksend.py similarity index 91% rename from libs/apprise/plugins/NotifyClickSend.py rename to libs/apprise/plugins/clicksend.py index 5e345fe10..92a2405e0 100644 --- a/libs/apprise/plugins/NotifyClickSend.py +++ b/libs/apprise/plugins/clicksend.py @@ -41,15 +41,12 @@ # import requests from json import dumps -from base64 import b64encode -from .NotifyBase import NotifyBase -from ..URLBase import PrivacyMode +from .base import NotifyBase +from ..url import PrivacyMode from ..common import NotifyType -from ..utils import is_phone_no -from ..utils import parse_phone_no -from ..utils import parse_bool -from ..AppriseLocale import gettext_lazy as _ +from ..utils.parse import is_phone_no, parse_phone_no, parse_bool +from ..locale import gettext_lazy as _ # Extend HTTP Error Messages CLICKSEND_HTTP_ERROR_MAP = { @@ -89,7 +86,7 @@ class NotifyClickSend(NotifyBase): # Define object templates templates = ( - '{schema}://{user}:{password}@{targets}', + '{schema}://{user}:{apikey}@{targets}', ) # Define our template tokens @@ -99,11 +96,12 @@ class NotifyClickSend(NotifyBase): 'type': 'string', 'required': True, }, - 'password': { - 'name': _('Password'), + 'apikey': { + 'name': _('API Key'), 'type': 'string', 'private': True, 'required': True, + 'map_to': 'password', }, 'target_phone': { 'name': _('Target Phone No'), @@ -124,6 +122,9 @@ class NotifyClickSend(NotifyBase): 'to': { 'alias_of': 'targets', }, + 'key': { + 'alias_of': 'apikey', + }, 'batch': { 'name': _('Batch Mode'), 'type': 'bool', @@ -174,9 +175,6 @@ def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): headers = { 'User-Agent': self.app_id, 'Content-Type': 'application/json; charset=utf-8', - 'Authorization': 'Basic {}'.format( - b64encode('{}:{}'.format( - self.user, self.password).encode('utf-8'))), } # error tracking (used for function return) @@ -208,6 +206,7 @@ def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): r = requests.post( self.notify_url, data=dumps(payload), + auth=(self.user, self.password), headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, @@ -284,6 +283,15 @@ def url(self, privacy=False, *args, **kwargs): params=NotifyClickSend.urlencode(params), ) + @property + def url_identifier(self): + """ + Returns all of the identifiers that make this URL unique from + another simliar one. Targets or end points should never be identified + here. + """ + return (self.secure_protocol, self.user, self.password) + def __len__(self): """ Returns the number of targets associated with this notification @@ -322,6 +330,12 @@ def parse_url(url): results['batch'] = \ parse_bool(results['qsd'].get('batch', False)) + # API Key + if 'key' in results['qsd'] and len(results['qsd']['key']): + # Extract the API Key from an argument + results['password'] = \ + NotifyClickSend.unquote(results['qsd']['key']) + # Support the 'to' variable so that we can support rooms this way too # The 'to' makes it easier to use yaml configuration if 'to' in results['qsd'] and len(results['qsd']['to']): diff --git a/libs/apprise/plugins/NotifyForm.py b/libs/apprise/plugins/custom_form.py similarity index 96% rename from libs/apprise/plugins/NotifyForm.py rename to libs/apprise/plugins/custom_form.py index 9690cd4f5..e9ffcbbb4 100644 --- a/libs/apprise/plugins/NotifyForm.py +++ b/libs/apprise/plugins/custom_form.py @@ -29,11 +29,11 @@ import re import requests -from .NotifyBase import NotifyBase -from ..URLBase import PrivacyMode +from .base import NotifyBase +from ..url import PrivacyMode from ..common import NotifyImageSize from ..common import NotifyType -from ..AppriseLocale import gettext_lazy as _ +from ..locale import gettext_lazy as _ class FORMPayloadField: @@ -272,62 +272,6 @@ def __init__(self, headers=None, method=None, payload=None, params=None, return - def url(self, privacy=False, *args, **kwargs): - """ - Returns the URL built dynamically based on specified arguments. - """ - - # Define any URL parameters - params = { - 'method': self.method, - } - - # Extend our parameters - params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) - - # Append our headers into our parameters - params.update({'+{}'.format(k): v for k, v in self.headers.items()}) - - # Append our GET params into our parameters - params.update({'-{}'.format(k): v for k, v in self.params.items()}) - - # Append our payload extra's into our parameters - params.update( - {':{}'.format(k): v for k, v in self.payload_extras.items()}) - params.update( - {':{}'.format(k): v for k, v in self.payload_overrides.items()}) - - if self.attach_as != self.attach_as_default: - # Provide Attach-As extension details - params['attach-as'] = self.attach_as - - # Determine Authentication - auth = '' - if self.user and self.password: - auth = '{user}:{password}@'.format( - user=NotifyForm.quote(self.user, safe=''), - password=self.pprint( - self.password, privacy, mode=PrivacyMode.Secret, safe=''), - ) - elif self.user: - auth = '{user}@'.format( - user=NotifyForm.quote(self.user, safe=''), - ) - - default_port = 443 if self.secure else 80 - - return '{schema}://{auth}{hostname}{port}{fullpath}?{params}'.format( - schema=self.secure_protocol if self.secure else self.protocol, - auth=auth, - # never encode hostname since we're expecting it to be a valid one - hostname=self.host, - port='' if self.port is None or self.port == default_port - else ':{}'.format(self.port), - fullpath=NotifyForm.quote(self.fullpath, safe='/') - if self.fullpath else '/', - params=NotifyForm.urlencode(params), - ) - def send(self, body, title='', notify_type=NotifyType.INFO, attach=None, **kwargs): """ @@ -358,7 +302,8 @@ def send(self, body, title='', notify_type=NotifyType.INFO, attach=None, files.append(( self.attach_as.format(no) if self.attach_multi_support else self.attach_as, ( - attachment.name, + attachment.name + if attachment.name else f'file{no:03}.dat', open(attachment.path, 'rb'), attachment.mimetype) )) @@ -486,6 +431,76 @@ def send(self, body, title='', notify_type=NotifyType.INFO, attach=None, return True + @property + def url_identifier(self): + """ + Returns all of the identifiers that make this URL unique from + another simliar one. Targets or end points should never be identified + here. + """ + return ( + self.secure_protocol if self.secure else self.protocol, + self.user, self.password, self.host, + self.port if self.port else (443 if self.secure else 80), + self.fullpath.rstrip('/'), + ) + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any URL parameters + params = { + 'method': self.method, + } + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + # Append our headers into our parameters + params.update({'+{}'.format(k): v for k, v in self.headers.items()}) + + # Append our GET params into our parameters + params.update({'-{}'.format(k): v for k, v in self.params.items()}) + + # Append our payload extra's into our parameters + params.update( + {':{}'.format(k): v for k, v in self.payload_extras.items()}) + params.update( + {':{}'.format(k): v for k, v in self.payload_overrides.items()}) + + if self.attach_as != self.attach_as_default: + # Provide Attach-As extension details + params['attach-as'] = self.attach_as + + # Determine Authentication + auth = '' + if self.user and self.password: + auth = '{user}:{password}@'.format( + user=NotifyForm.quote(self.user, safe=''), + password=self.pprint( + self.password, privacy, mode=PrivacyMode.Secret, safe=''), + ) + elif self.user: + auth = '{user}@'.format( + user=NotifyForm.quote(self.user, safe=''), + ) + + default_port = 443 if self.secure else 80 + + return '{schema}://{auth}{hostname}{port}{fullpath}?{params}'.format( + schema=self.secure_protocol if self.secure else self.protocol, + auth=auth, + # never encode hostname since we're expecting it to be a valid one + hostname=self.host, + port='' if self.port is None or self.port == default_port + else ':{}'.format(self.port), + fullpath=NotifyForm.quote(self.fullpath, safe='/') + if self.fullpath else '/', + params=NotifyForm.urlencode(params), + ) + @staticmethod def parse_url(url): """ diff --git a/libs/apprise/plugins/NotifyJSON.py b/libs/apprise/plugins/custom_json.py similarity index 89% rename from libs/apprise/plugins/NotifyJSON.py rename to libs/apprise/plugins/custom_json.py index 182ff77cf..03585c9ef 100644 --- a/libs/apprise/plugins/NotifyJSON.py +++ b/libs/apprise/plugins/custom_json.py @@ -27,14 +27,14 @@ # POSSIBILITY OF SUCH DAMAGE. import requests -import base64 from json import dumps -from .NotifyBase import NotifyBase -from ..URLBase import PrivacyMode +from .. import exception +from .base import NotifyBase +from ..url import PrivacyMode from ..common import NotifyImageSize from ..common import NotifyType -from ..AppriseLocale import gettext_lazy as _ +from ..locale import gettext_lazy as _ class JSONPayloadField: @@ -195,56 +195,6 @@ def __init__(self, headers=None, method=None, payload=None, params=None, return - def url(self, privacy=False, *args, **kwargs): - """ - Returns the URL built dynamically based on specified arguments. - """ - - # Define any URL parameters - params = { - 'method': self.method, - } - - # Extend our parameters - params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) - - # Append our headers into our parameters - params.update({'+{}'.format(k): v for k, v in self.headers.items()}) - - # Append our GET params into our parameters - params.update({'-{}'.format(k): v for k, v in self.params.items()}) - - # Append our payload extra's into our parameters - params.update( - {':{}'.format(k): v for k, v in self.payload_extras.items()}) - - # Determine Authentication - auth = '' - if self.user and self.password: - auth = '{user}:{password}@'.format( - user=NotifyJSON.quote(self.user, safe=''), - password=self.pprint( - self.password, privacy, mode=PrivacyMode.Secret, safe=''), - ) - elif self.user: - auth = '{user}@'.format( - user=NotifyJSON.quote(self.user, safe=''), - ) - - default_port = 443 if self.secure else 80 - - return '{schema}://{auth}{hostname}{port}{fullpath}?{params}'.format( - schema=self.secure_protocol if self.secure else self.protocol, - auth=auth, - # never encode hostname since we're expecting it to be a valid one - hostname=self.host, - port='' if self.port is None or self.port == default_port - else ':{}'.format(self.port), - fullpath=NotifyJSON.quote(self.fullpath, safe='/') - if self.fullpath else '/', - params=NotifyJSON.urlencode(params), - ) - def send(self, body, title='', notify_type=NotifyType.INFO, attach=None, **kwargs): """ @@ -263,33 +213,34 @@ def send(self, body, title='', notify_type=NotifyType.INFO, attach=None, # Track our potential attachments attachments = [] if attach and self.attachment_support: - for attachment in attach: + for no, attachment in enumerate(attach, start=1): # Perform some simple error checking if not attachment: # We could not access the attachment self.logger.error( - 'Could not access attachment {}.'.format( + 'Could not access Custom JSON attachment {}.'.format( attachment.url(privacy=True))) return False try: - with open(attachment.path, 'rb') as f: - # Output must be in a DataURL format (that's what - # PushSafer calls it): - attachments.append({ - 'filename': attachment.name, - 'base64': base64.b64encode(f.read()) - .decode('utf-8'), - 'mimetype': attachment.mimetype, - }) - - except (OSError, IOError) as e: - self.logger.warning( - 'An I/O error occurred while reading {}.'.format( - attachment.name if attachment else 'attachment')) - self.logger.debug('I/O Exception: %s' % str(e)) + attachments.append({ + "filename": attachment.name + if attachment.name else f'file{no:03}.dat', + 'base64': attachment.base64(), + 'mimetype': attachment.mimetype, + }) + + except exception.AppriseException: + # We could not access the attachment + self.logger.error( + 'Could not access Custom JSON attachment {}.'.format( + attachment.url(privacy=True))) return False + self.logger.debug( + 'Appending Custom JSON attachment {}'.format( + attachment.url(privacy=True))) + # Prepare JSON Object payload = { JSONPayloadField.VERSION: self.json_version, @@ -395,6 +346,70 @@ def send(self, body, title='', notify_type=NotifyType.INFO, attach=None, return True + @property + def url_identifier(self): + """ + Returns all of the identifiers that make this URL unique from + another simliar one. Targets or end points should never be identified + here. + """ + return ( + self.secure_protocol if self.secure else self.protocol, + self.user, self.password, self.host, + self.port if self.port else (443 if self.secure else 80), + self.fullpath.rstrip('/'), + ) + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any URL parameters + params = { + 'method': self.method, + } + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + # Append our headers into our parameters + params.update({'+{}'.format(k): v for k, v in self.headers.items()}) + + # Append our GET params into our parameters + params.update({'-{}'.format(k): v for k, v in self.params.items()}) + + # Append our payload extra's into our parameters + params.update( + {':{}'.format(k): v for k, v in self.payload_extras.items()}) + + # Determine Authentication + auth = '' + if self.user and self.password: + auth = '{user}:{password}@'.format( + user=NotifyJSON.quote(self.user, safe=''), + password=self.pprint( + self.password, privacy, mode=PrivacyMode.Secret, safe=''), + ) + elif self.user: + auth = '{user}@'.format( + user=NotifyJSON.quote(self.user, safe=''), + ) + + default_port = 443 if self.secure else 80 + + return '{schema}://{auth}{hostname}{port}{fullpath}?{params}'.format( + schema=self.secure_protocol if self.secure else self.protocol, + auth=auth, + # never encode hostname since we're expecting it to be a valid one + hostname=self.host, + port='' if self.port is None or self.port == default_port + else ':{}'.format(self.port), + fullpath=NotifyJSON.quote(self.fullpath, safe='/') + if self.fullpath else '/', + params=NotifyJSON.urlencode(params), + ) + @staticmethod def parse_url(url): """ diff --git a/libs/apprise/plugins/NotifyXML.py b/libs/apprise/plugins/custom_xml.py similarity index 89% rename from libs/apprise/plugins/NotifyXML.py rename to libs/apprise/plugins/custom_xml.py index 21ccb79d3..8bfff3ece 100644 --- a/libs/apprise/plugins/NotifyXML.py +++ b/libs/apprise/plugins/custom_xml.py @@ -28,13 +28,13 @@ import re import requests -import base64 -from .NotifyBase import NotifyBase -from ..URLBase import PrivacyMode +from .. import exception +from .base import NotifyBase +from ..url import PrivacyMode from ..common import NotifyImageSize from ..common import NotifyType -from ..AppriseLocale import gettext_lazy as _ +from ..locale import gettext_lazy as _ class XMLPayloadField: @@ -242,58 +242,6 @@ def __init__(self, headers=None, method=None, payload=None, params=None, return - def url(self, privacy=False, *args, **kwargs): - """ - Returns the URL built dynamically based on specified arguments. - """ - - # Define any URL parameters - params = { - 'method': self.method, - } - - # Extend our parameters - params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) - - # Append our headers into our parameters - params.update({'+{}'.format(k): v for k, v in self.headers.items()}) - - # Append our GET params into our parameters - params.update({'-{}'.format(k): v for k, v in self.params.items()}) - - # Append our payload extra's into our parameters - params.update( - {':{}'.format(k): v for k, v in self.payload_extras.items()}) - params.update( - {':{}'.format(k): v for k, v in self.payload_overrides.items()}) - - # Determine Authentication - auth = '' - if self.user and self.password: - auth = '{user}:{password}@'.format( - user=NotifyXML.quote(self.user, safe=''), - password=self.pprint( - self.password, privacy, mode=PrivacyMode.Secret, safe=''), - ) - elif self.user: - auth = '{user}@'.format( - user=NotifyXML.quote(self.user, safe=''), - ) - - default_port = 443 if self.secure else 80 - - return '{schema}://{auth}{hostname}{port}{fullpath}?{params}'.format( - schema=self.secure_protocol if self.secure else self.protocol, - auth=auth, - # never encode hostname since we're expecting it to be a valid one - hostname=self.host, - port='' if self.port is None or self.port == default_port - else ':{}'.format(self.port), - fullpath=NotifyXML.quote(self.fullpath, safe='/') - if self.fullpath else '/', - params=NotifyXML.urlencode(params), - ) - def send(self, body, title='', notify_type=NotifyType.INFO, attach=None, **kwargs): """ @@ -339,35 +287,39 @@ def send(self, body, title='', notify_type=NotifyType.INFO, attach=None, attachments = [] if attach and self.attachment_support: - for attachment in attach: + for no, attachment in enumerate(attach, start=1): # Perform some simple error checking if not attachment: # We could not access the attachment self.logger.error( - 'Could not access attachment {}.'.format( + 'Could not access Custom XML attachment {}.'.format( attachment.url(privacy=True))) return False try: - with open(attachment.path, 'rb') as f: - # Prepare our Attachment in Base64 - entry = \ - ''.format( - NotifyXML.escape_html( - attachment.name, whitespace=False), - NotifyXML.escape_html( - attachment.mimetype, whitespace=False)) - entry += base64.b64encode(f.read()).decode('utf-8') - entry += '' - attachments.append(entry) - - except (OSError, IOError) as e: - self.logger.warning( - 'An I/O error occurred while reading {}.'.format( - attachment.name if attachment else 'attachment')) - self.logger.debug('I/O Exception: %s' % str(e)) + # Prepare our Attachment in Base64 + entry = \ + ''.format( + NotifyXML.escape_html( + attachment.name if attachment.name + else f'file{no:03}.dat', whitespace=False), + NotifyXML.escape_html( + attachment.mimetype, whitespace=False)) + entry += attachment.base64() + entry += '' + attachments.append(entry) + + except exception.AppriseException: + # We could not access the attachment + self.logger.error( + 'Could not access Custom XML attachment {}.'.format( + attachment.url(privacy=True))) return False + self.logger.debug( + 'Appending Custom XML attachment {}'.format( + attachment.url(privacy=True))) + # Update our xml_attachments record: xml_attachments = \ '' + \ @@ -467,6 +419,72 @@ def send(self, body, title='', notify_type=NotifyType.INFO, attach=None, return True + @property + def url_identifier(self): + """ + Returns all of the identifiers that make this URL unique from + another simliar one. Targets or end points should never be identified + here. + """ + return ( + self.secure_protocol if self.secure else self.protocol, + self.user, self.password, self.host, + self.port if self.port else (443 if self.secure else 80), + self.fullpath.rstrip('/'), + ) + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any URL parameters + params = { + 'method': self.method, + } + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + # Append our headers into our parameters + params.update({'+{}'.format(k): v for k, v in self.headers.items()}) + + # Append our GET params into our parameters + params.update({'-{}'.format(k): v for k, v in self.params.items()}) + + # Append our payload extra's into our parameters + params.update( + {':{}'.format(k): v for k, v in self.payload_extras.items()}) + params.update( + {':{}'.format(k): v for k, v in self.payload_overrides.items()}) + + # Determine Authentication + auth = '' + if self.user and self.password: + auth = '{user}:{password}@'.format( + user=NotifyXML.quote(self.user, safe=''), + password=self.pprint( + self.password, privacy, mode=PrivacyMode.Secret, safe=''), + ) + elif self.user: + auth = '{user}@'.format( + user=NotifyXML.quote(self.user, safe=''), + ) + + default_port = 443 if self.secure else 80 + + return '{schema}://{auth}{hostname}{port}{fullpath}?{params}'.format( + schema=self.secure_protocol if self.secure else self.protocol, + auth=auth, + # never encode hostname since we're expecting it to be a valid one + hostname=self.host, + port='' if self.port is None or self.port == default_port + else ':{}'.format(self.port), + fullpath=NotifyXML.quote(self.fullpath, safe='/') + if self.fullpath else '/', + params=NotifyXML.urlencode(params), + ) + @staticmethod def parse_url(url): """ diff --git a/libs/apprise/plugins/NotifyD7Networks.py b/libs/apprise/plugins/d7networks.py similarity index 97% rename from libs/apprise/plugins/NotifyD7Networks.py rename to libs/apprise/plugins/d7networks.py index 906ec2fb9..8b35458d5 100644 --- a/libs/apprise/plugins/NotifyD7Networks.py +++ b/libs/apprise/plugins/d7networks.py @@ -39,13 +39,11 @@ from json import dumps from json import loads -from .NotifyBase import NotifyBase +from .base import NotifyBase from ..common import NotifyType -from ..utils import is_phone_no -from ..utils import parse_phone_no -from ..utils import validate_regex -from ..utils import parse_bool -from ..AppriseLocale import gettext_lazy as _ +from ..utils.parse import ( + is_phone_no, parse_phone_no, validate_regex, parse_bool) +from ..locale import gettext_lazy as _ # Extend HTTP Error Messages D7NETWORKS_HTTP_ERROR_MAP = { @@ -354,6 +352,15 @@ def url(self, privacy=False, *args, **kwargs): [NotifyD7Networks.quote(x, safe='') for x in self.targets]), params=NotifyD7Networks.urlencode(params)) + @property + def url_identifier(self): + """ + Returns all of the identifiers that make this URL unique from + another simliar one. Targets or end points should never be identified + here. + """ + return (self.secure_protocol, self.token) + def __len__(self): """ Returns the number of targets associated with this notification diff --git a/libs/apprise/plugins/NotifyDapnet.py b/libs/apprise/plugins/dapnet.py similarity index 96% rename from libs/apprise/plugins/NotifyDapnet.py rename to libs/apprise/plugins/dapnet.py index ae7199c94..2d25759eb 100644 --- a/libs/apprise/plugins/NotifyDapnet.py +++ b/libs/apprise/plugins/dapnet.py @@ -51,14 +51,12 @@ import requests from requests.auth import HTTPBasicAuth -from .NotifyBase import NotifyBase -from ..AppriseLocale import gettext_lazy as _ -from ..URLBase import PrivacyMode +from .base import NotifyBase +from ..locale import gettext_lazy as _ +from ..url import PrivacyMode from ..common import NotifyType -from ..utils import is_call_sign -from ..utils import parse_call_sign -from ..utils import parse_list -from ..utils import parse_bool +from ..utils.parse import ( + is_call_sign, parse_call_sign, parse_list, parse_bool) class DapnetPriority: @@ -346,6 +344,15 @@ def url(self, privacy=False, *args, **kwargs): params=NotifyDapnet.urlencode(params), ) + @property + def url_identifier(self): + """ + Returns all of the identifiers that make this URL unique from + another simliar one. Targets or end points should never be identified + here. + """ + return (self.secure_protocol, self.user, self.password) + def __len__(self): """ Returns the number of targets associated with this notification diff --git a/libs/apprise/plugins/NotifyDBus.py b/libs/apprise/plugins/dbus.py similarity index 98% rename from libs/apprise/plugins/NotifyDBus.py rename to libs/apprise/plugins/dbus.py index 52e119813..5d99c7e0e 100644 --- a/libs/apprise/plugins/NotifyDBus.py +++ b/libs/apprise/plugins/dbus.py @@ -27,11 +27,11 @@ # POSSIBILITY OF SUCH DAMAGE. import sys -from .NotifyBase import NotifyBase +from .base import NotifyBase from ..common import NotifyImageSize from ..common import NotifyType -from ..utils import parse_bool -from ..AppriseLocale import gettext_lazy as _ +from ..utils.parse import parse_bool +from ..locale import gettext_lazy as _ # Default our global support flag NOTIFY_DBUS_SUPPORT_ENABLED = False @@ -173,7 +173,6 @@ class NotifyDBus(NotifyBase): # object if we were to reference, we wouldn't be backwards compatible with # Python v2. So converting the result set back into a list makes us # compatible - # TODO: Review after dropping support for Python 2. protocol = list(MAINLOOP_MAP.keys()) # A URL that takes you to the setup/help of the specific protocol @@ -196,6 +195,10 @@ class NotifyDBus(NotifyBase): dbus_interface = 'org.freedesktop.Notifications' dbus_setting_location = '/org/freedesktop/Notifications' + # No URL Identifier will be defined for this service as there simply isn't + # enough details to uniquely identify one dbus:// from another. + url_identifier = False + # Define object templates templates = ( '{schema}://', diff --git a/libs/apprise/plugins/NotifyDingTalk.py b/libs/apprise/plugins/dingtalk.py similarity index 96% rename from libs/apprise/plugins/NotifyDingTalk.py rename to libs/apprise/plugins/dingtalk.py index d4a492fc7..2380d2e0a 100644 --- a/libs/apprise/plugins/NotifyDingTalk.py +++ b/libs/apprise/plugins/dingtalk.py @@ -34,13 +34,12 @@ import requests from json import dumps -from .NotifyBase import NotifyBase -from ..URLBase import PrivacyMode +from .base import NotifyBase +from ..url import PrivacyMode from ..common import NotifyFormat from ..common import NotifyType -from ..utils import parse_list -from ..utils import validate_regex -from ..AppriseLocale import gettext_lazy as _ +from ..utils.parse import parse_list, validate_regex +from ..locale import gettext_lazy as _ # Register at https://dingtalk.com # - Download their PC based software as it is the only way you can create @@ -310,6 +309,15 @@ def url(self, privacy=False, *args, **kwargs): [NotifyDingTalk.quote(x, safe='') for x in self.targets]), args=NotifyDingTalk.urlencode(args)) + @property + def url_identifier(self): + """ + Returns all of the identifiers that make this URL unique from + another simliar one. Targets or end points should never be identified + here. + """ + return (self.secure_protocol, self.secret, self.token) + def __len__(self): """ Returns the number of targets associated with this notification diff --git a/libs/apprise/plugins/NotifyDiscord.py b/libs/apprise/plugins/discord.py similarity index 98% rename from libs/apprise/plugins/NotifyDiscord.py rename to libs/apprise/plugins/discord.py index 82d764f50..d2eb7541d 100644 --- a/libs/apprise/plugins/NotifyDiscord.py +++ b/libs/apprise/plugins/discord.py @@ -50,14 +50,13 @@ from datetime import datetime from datetime import timezone -from .NotifyBase import NotifyBase +from .base import NotifyBase from ..common import NotifyImageSize from ..common import NotifyFormat from ..common import NotifyType -from ..utils import parse_bool -from ..utils import validate_regex -from ..AppriseLocale import gettext_lazy as _ -from ..attachment.AttachBase import AttachBase +from ..utils.parse import parse_bool, validate_regex +from ..locale import gettext_lazy as _ +from ..attachment.base import AttachBase # Used to detect user/role IDs @@ -607,6 +606,15 @@ def url(self, privacy=False, *args, **kwargs): params=NotifyDiscord.urlencode(params), ) + @property + def url_identifier(self): + """ + Returns all of the identifiers that make this URL unique from + another simliar one. Targets or end points should never be identified + here. + """ + return (self.secure_protocol, self.webhook_id, self.webhook_token) + @staticmethod def parse_url(url): """ diff --git a/libs/apprise/plugins/email/__init__.py b/libs/apprise/plugins/email/__init__.py new file mode 100644 index 000000000..31afabcab --- /dev/null +++ b/libs/apprise/plugins/email/__init__.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2024, Chris Caron +# +# 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. + +from email import charset + +from .base import NotifyEmail +from .common import ( + AppriseEmailException, EmailMessage, SecureMailMode, SECURE_MODES, + WebBaseLogin) +from .templates import EMAIL_TEMPLATES + +# Globally Default encoding mode set to Quoted Printable. +charset.add_charset('utf-8', charset.QP, charset.QP, 'utf-8') + +__all__ = [ + # Reference + 'NotifyEmail', + + # Pretty Good Privacy + 'ApprisePGPController', 'ApprisePGPException', + + # Other + 'AppriseEmailException', 'EmailMessage', 'SecureMailMode', 'SECURE_MODES', + 'WebBaseLogin', + + # Additional entries that may be useful to some developers + 'EMAIL_TEMPLATES', 'PGP_SUPPORT', +] diff --git a/libs/apprise/plugins/NotifyEmail.py b/libs/apprise/plugins/email/base.py similarity index 66% rename from libs/apprise/plugins/NotifyEmail.py rename to libs/apprise/plugins/email/base.py index 80f88bf61..7f29fa26c 100644 --- a/libs/apprise/plugins/NotifyEmail.py +++ b/libs/apprise/plugins/email/base.py @@ -26,312 +26,32 @@ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. -import dataclasses import re import smtplib -import typing as t from email.mime.text import MIMEText from email.mime.application import MIMEApplication from email.mime.multipart import MIMEMultipart +from email.mime.base import MIMEBase from email.utils import formataddr, make_msgid from email.header import Header -from email import charset from socket import error as SocketError from datetime import datetime from datetime import timezone -from .NotifyBase import NotifyBase -from ..URLBase import PrivacyMode -from ..common import NotifyFormat, NotifyType -from ..conversion import convert_between -from ..utils import is_email, parse_emails, is_hostname -from ..AppriseLocale import gettext_lazy as _ -from ..logger import logger - -# Globally Default encoding mode set to Quoted Printable. -charset.add_charset('utf-8', charset.QP, charset.QP, 'utf-8') - - -class WebBaseLogin: - """ - This class is just used in conjunction of the default emailers - to best formulate a login to it using the data detected - """ - # User Login must be Email Based - EMAIL = 'Email' - - # User Login must UserID Based - USERID = 'UserID' - - -# Secure Email Modes -class SecureMailMode: - INSECURE = "insecure" - SSL = "ssl" - STARTTLS = "starttls" - - -# Define all of the secure modes (used during validation) -SECURE_MODES = { - SecureMailMode.STARTTLS: { - 'default_port': 587, - }, - SecureMailMode.SSL: { - 'default_port': 465, - }, - SecureMailMode.INSECURE: { - 'default_port': 25, - }, -} - -# To attempt to make this script stupid proof, if we detect an email address -# that is part of the this table, we can pre-use a lot more defaults if they -# aren't otherwise specified on the users input. -EMAIL_TEMPLATES = ( - # Google GMail - ( - 'Google Mail', - re.compile( - r'^((?P